
Security News
Axios Maintainer Confirms Social Engineering Attack Behind npm Compromise
Axios compromise traced to social engineering, showing how attacks on maintainers can bypass controls and expose the broader software supply chain.
@kingdoo/editor
Advanced tools
Image-only BlockNote rich text editor with S3 upload and optimized loading spinner
🖼️ 이미지 전용 BlockNote 기반 Rich Text 에디터
| 특징 | 설명 |
|---|---|
| 🖼️ 이미지 전용 | 이미지 업로드/드래그앤드롭만 지원 (비디오/오디오/파일 제거) |
| ☁️ S3 연동 | Presigned URL 기반 S3 업로드 내장 |
| 🎯 커스텀 업로더 | 자체 업로드 로직 적용 가능 |
| ⏳ 로딩 스피너 | 이미지 업로드 중 자동 스피너 표시 |
| 🚀 애니메이션 최적화 | 기본 애니메이션 비활성화로 성능 향상 |
| 📝 TypeScript | 완전한 타입 안전성 |
| 🎨 테마 지원 | 라이트/다크 테마 및 커스텀 테마 지원 |
| 📱 반응형 | 모바일/데스크톱 최적화 |
PNG, JPEG/JPG, GIF (애니메이션 포함), WebP, BMP, SVG
# npm
npm install @kingdoo/editor
# yarn
yarn add @kingdoo/editor
# pnpm
pnpm add @kingdoo/editor
{
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
import "@kingdoo/editor/style.css";
⚠️ 중요: CSS를 임포트하지 않으면 에디터가 정상적으로 렌더링되지 않습니다.
import { LumirEditor } from "@kingdoo/editor";
import "@kingdoo/editor/style.css";
export default function App() {
return (
<div className="w-full h-[400px]">
<LumirEditor onContentChange={(blocks) => console.log(blocks)} />
</div>
);
}
"use client";
import dynamic from "next/dynamic";
import "@kingdoo/editor/style.css";
const LumirEditor = dynamic(
() => import("@kingdoo/editor").then((m) => ({ default: m.LumirEditor })),
{ ssr: false }
);
export default function EditorPage() {
return (
<div className="w-full h-[500px]">
<LumirEditor
onContentChange={(blocks) => console.log("Content:", blocks)}
/>
</div>
);
}
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
initialContent | DefaultPartialBlock[] | string | undefined | 초기 콘텐츠 (블록 배열 또는 JSON 문자열) |
initialEmptyBlocks | number | 3 | 초기 빈 블록 개수 |
placeholder | string | undefined | 첫 번째 블록의 placeholder 텍스트 |
uploadFile | (file: File) => Promise<string> | undefined | 커스텀 파일 업로드 함수 |
s3Upload | S3UploaderConfig | undefined | S3 업로드 설정 |
tables | TableConfig | {...} | 테이블 기능 설정 |
heading | { levels?: (1|2|3|4|5|6)[] } | { levels: [1,2,3,4,5,6] } | 헤딩 레벨 설정 |
defaultStyles | boolean | true | 기본 스타일 활성화 |
disableExtensions | string[] | [] | 비활성화할 확장 기능 목록 |
tabBehavior | "prefer-navigate-ui" | "prefer-indent" | "prefer-navigate-ui" | 탭 키 동작 |
trailingBlock | boolean | true | 마지막에 빈 블록 자동 추가 |
allowVideoUpload | boolean | false | 비디오 업로드 허용 (기본 비활성) |
allowAudioUpload | boolean | false | 오디오 업로드 허용 (기본 비활성) |
allowFileUpload | boolean | false | 일반 파일 업로드 허용 (기본 비활성) |
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
editable | boolean | true | 편집 가능 여부 |
theme | "light" | "dark" | ThemeObject | "light" | 에디터 테마 |
formattingToolbar | boolean | true | 서식 툴바 표시 |
linkToolbar | boolean | true | 링크 툴바 표시 |
sideMenu | boolean | true | 사이드 메뉴 표시 |
sideMenuAddButton | boolean | false | 사이드 메뉴 + 버튼 표시 (false시 드래그 핸들만 표시) |
emojiPicker | boolean | true | 이모지 선택기 표시 |
filePanel | boolean | true | 파일 패널 표시 |
tableHandles | boolean | true | 테이블 핸들 표시 |
className | string | "" | 컨테이너 CSS 클래스 |
| Prop | 타입 | 설명 |
|---|---|---|
onContentChange | (blocks: DefaultPartialBlock[]) => void | 콘텐츠 변경 시 호출 |
onSelectionChange | () => void | 선택 영역 변경 시 호출 |
interface S3UploaderConfig {
apiEndpoint: string; // Presigned URL API 엔드포인트 (필수)
env: "development" | "production"; // 환경 (필수)
path: string; // S3 경로 (필수)
}
interface TableConfig {
splitCells?: boolean; // 셀 분할 (기본: true)
cellBackgroundColor?: boolean; // 셀 배경색 (기본: true)
cellTextColor?: boolean; // 셀 텍스트 색상 (기본: true)
headers?: boolean; // 헤더 행 (기본: true)
}
Presigned URL을 사용한 안전한 S3 업로드 방식입니다.
<LumirEditor
s3Upload={{
apiEndpoint: "/api/s3/presigned",
env: "development",
path: "blog/images",
}}
onContentChange={(blocks) => console.log(blocks)}
/>
S3 파일 저장 경로 구조:
{env}/{path}/{filename}
예: development/blog/images/my-image.png
API 엔드포인트 응답 예시:
{
"presignedUrl": "https://s3.amazonaws.com/bucket/...",
"publicUrl": "https://cdn.example.com/development/blog/images/my-image.png"
}
자체 업로드 로직을 사용할 때 활용합니다.
<LumirEditor
uploadFile={async (file) => {
const formData = new FormData();
formData.append("image", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
const data = await response.json();
return data.url; // 업로드된 이미지의 URL 반환
}}
/>
S3 업로더를 직접 생성하여 사용할 수 있습니다.
import { LumirEditor, createS3Uploader } from "@kingdoo/editor";
// S3 업로더 생성
const s3Uploader = createS3Uploader({
apiEndpoint: "/api/s3/presigned",
env: "production",
path: "uploads/images",
});
// 에디터에 적용
<LumirEditor uploadFile={s3Uploader} />;
// 또는 별도로 사용
const imageUrl = await s3Uploader(imageFile);
uploadFile prop이 있으면 우선 사용uploadFile이 없고 s3Upload가 있으면 S3 업로드 사용콘텐츠 관리 유틸리티 클래스입니다.
import { ContentUtils } from "@kingdoo/editor";
// JSON 문자열 유효성 검증
const isValid = ContentUtils.isValidJSONString('[{"type":"paragraph"}]');
// true
// JSON 문자열을 블록 배열로 파싱
const blocks = ContentUtils.parseJSONContent(jsonString);
// DefaultPartialBlock[] | null
// 기본 빈 블록 생성
const emptyBlock = ContentUtils.createDefaultBlock();
// { type: "paragraph", props: {...}, content: [...], children: [] }
// 콘텐츠 유효성 검증 및 기본값 설정
const validatedContent = ContentUtils.validateContent(content, 3);
// 빈 콘텐츠면 3개의 빈 블록 반환
에디터 설정 유틸리티 클래스입니다.
import { EditorConfig } from "@kingdoo/editor";
// 테이블 기본 설정 가져오기
const tableConfig = EditorConfig.getDefaultTableConfig({
splitCells: true,
headers: false,
});
// 헤딩 기본 설정 가져오기
const headingConfig = EditorConfig.getDefaultHeadingConfig({
levels: [1, 2, 3],
});
// 비활성화 확장 목록 생성
const disabledExt = EditorConfig.getDisabledExtensions(
["codeBlock"], // 사용자 정의 비활성 확장
false, // allowVideo
false, // allowAudio
false // allowFile
);
// ["codeBlock", "video", "audio", "file"]
조건부 className 결합 유틸리티입니다.
import { cn } from "@kingdoo/editor";
<LumirEditor
className={cn(
"min-h-[400px] rounded-lg",
isFullscreen && "fixed inset-0 z-50",
isDarkMode && "dark-theme"
)}
/>;
import type {
// 에디터 Props
LumirEditorProps,
// 에디터 인스턴스 타입
EditorType,
// 블록 관련 타입
DefaultPartialBlock,
DefaultBlockSchema,
DefaultInlineContentSchema,
DefaultStyleSchema,
PartialBlock,
BlockNoteEditor,
} from "@kingdoo/editor";
import type { S3UploaderConfig } from "@kingdoo/editor";
interface LumirEditorProps {
// === Editor Options ===
initialContent?: DefaultPartialBlock[] | string;
initialEmptyBlocks?: number;
placeholder?: string;
uploadFile?: (file: File) => Promise<string>;
s3Upload?: {
apiEndpoint: string;
env: "development" | "production";
path: string;
};
allowVideoUpload?: boolean;
allowAudioUpload?: boolean;
allowFileUpload?: boolean;
tables?: {
splitCells?: boolean;
cellBackgroundColor?: boolean;
cellTextColor?: boolean;
headers?: boolean;
};
heading?: { levels?: (1 | 2 | 3 | 4 | 5 | 6)[] };
defaultStyles?: boolean;
disableExtensions?: string[];
tabBehavior?: "prefer-navigate-ui" | "prefer-indent";
trailingBlock?: boolean;
// === View Options ===
editable?: boolean;
theme?:
| "light"
| "dark"
| Partial<Record<string, unknown>>
| {
light: Partial<Record<string, unknown>>;
dark: Partial<Record<string, unknown>>;
};
formattingToolbar?: boolean;
linkToolbar?: boolean;
sideMenu?: boolean;
sideMenuAddButton?: boolean;
emojiPicker?: boolean;
filePanel?: boolean;
tableHandles?: boolean;
onSelectionChange?: () => void;
className?: string;
// === Callbacks ===
onContentChange?: (content: DefaultPartialBlock[]) => void;
}
import { LumirEditor } from "@kingdoo/editor";
import "@kingdoo/editor/style.css";
function BasicEditor() {
return (
<div className="h-[400px]">
<LumirEditor />
</div>
);
}
// 방법 1: 블록 배열
<LumirEditor
initialContent={[
{
type: "heading",
props: { level: 1 },
content: [{ type: "text", text: "제목입니다", styles: {} }],
},
{
type: "paragraph",
content: [{ type: "text", text: "본문 내용...", styles: {} }],
},
]}
/>
// 방법 2: JSON 문자열
<LumirEditor
initialContent='[{"type":"paragraph","content":[{"type":"text","text":"Hello World","styles":{}}]}]'
/>
<LumirEditor
editable={false}
initialContent={savedContent}
sideMenu={false}
formattingToolbar={false}
/>
<LumirEditor theme="dark" className="bg-gray-900 rounded-lg" />
<LumirEditor
s3Upload={{
apiEndpoint: "/api/s3/presigned",
env: process.env.NODE_ENV as "development" | "production",
path: "articles/images",
}}
onContentChange={(blocks) => {
// 저장 로직
saveToDatabase(JSON.stringify(blocks));
}}
/>
<div className="w-full h-64 md:h-96 lg:h-[600px]">
<LumirEditor className="h-full rounded-md md:rounded-lg shadow-sm md:shadow-md" />
</div>
<LumirEditor
tables={{
splitCells: true,
cellBackgroundColor: true,
cellTextColor: false, // 셀 텍스트 색상 비활성
headers: true,
}}
heading={{
levels: [1, 2, 3], // H4-H6 비활성
}}
/>
import { useState, useEffect } from "react";
import { LumirEditor, ContentUtils } from "@kingdoo/editor";
function EditorWithSave() {
const [content, setContent] = useState<string>("");
// 저장된 콘텐츠 불러오기
useEffect(() => {
const saved = localStorage.getItem("editor-content");
if (saved && ContentUtils.isValidJSONString(saved)) {
setContent(saved);
}
}, []);
// 콘텐츠 저장
const handleContentChange = (blocks) => {
const jsonContent = JSON.stringify(blocks);
localStorage.setItem("editor-content", jsonContent);
};
return (
<LumirEditor
initialContent={content}
onContentChange={handleContentChange}
/>
);
}
/* 메인 컨테이너 */
.lumirEditor {
width: 100%;
height: 100%;
min-width: 200px;
overflow: auto;
background-color: #ffffff;
}
/* 에디터 내용 영역 */
.lumirEditor .bn-editor {
font-family: "Pretendard", "Noto Sans KR", -apple-system, sans-serif;
padding: 5px 10px 0 25px;
}
/* 문단 블록 */
.lumirEditor [data-content-type="paragraph"] {
font-size: 14px;
}
/* 업로드 스피너 */
.lumirEditor-upload-overlay {
...;
}
.lumirEditor-spinner {
...;
}
import { LumirEditor, cn } from "@kingdoo/editor";
<LumirEditor
className={cn(
"min-h-[400px] rounded-xl",
"border border-gray-200 shadow-lg",
"focus-within:ring-2 focus-within:ring-blue-500"
)}
/>;
/* globals.css */
.my-editor .bn-editor {
padding-left: 30px;
padding-right: 20px;
font-size: 16px;
}
.my-editor [data-content-type="heading"] {
font-weight: 700;
margin-top: 24px;
}
<LumirEditor className="my-editor" />
| 항목 | 체크 |
|---|---|
| CSS 임포트 | import "@kingdoo/editor/style.css"; |
| 컨테이너 높이 설정 | 부모 요소에 높이 지정 필수 |
| Next.js SSR 비활성화 | dynamic(..., { ssr: false }) 사용 |
| React 버전 | 18.0.0 이상 필요 |
// ❌ 잘못된 사용
<LumirEditor />;
// ✅ 올바른 사용 - CSS 임포트 필요
import "@kingdoo/editor/style.css";
<LumirEditor />;
// ❌ 잘못된 사용
import { LumirEditor } from "@kingdoo/editor";
// ✅ 올바른 사용 - dynamic import 사용
const LumirEditor = dynamic(
() => import("@kingdoo/editor").then((m) => ({ default: m.LumirEditor })),
{ ssr: false }
);
// ❌ 잘못된 사용
<LumirEditor />
// ✅ 올바른 사용 - 부모 요소에 높이 설정
<div className="h-[400px]">
<LumirEditor />
</div>
// uploadFile 또는 s3Upload 중 하나 반드시 설정
<LumirEditor
uploadFile={async (file) => {
// 업로드 로직
return imageUrl;
}}
// 또는
s3Upload={{
apiEndpoint: "/api/s3/presigned",
env: "development",
path: "images",
}}
/>
animations: false로 설정되어 성능 최적화됨@kingdoo/editor/
├── dist/ # 빌드 출력
│ ├── index.js # CommonJS 빌드
│ ├── index.mjs # ESM 빌드
│ ├── index.d.ts # TypeScript 타입 정의
│ └── style.css # 스타일시트
├── src/
│ ├── components/
│ │ └── LumirEditor.tsx # 메인 에디터 컴포넌트
│ ├── types/
│ │ ├── editor.ts # 에디터 타입 정의
│ │ └── index.ts # 타입 export
│ ├── utils/
│ │ ├── cn.ts # className 유틸리티
│ │ └── s3-uploader.ts # S3 업로더
│ ├── index.ts # 메인 export
│ └── style.css # 소스 스타일
└── examples/
└── tailwind-integration.md # Tailwind 통합 가이드
MIT License
FAQs
Image-only BlockNote rich text editor with S3 upload and optimized loading spinner
We found that @kingdoo/editor demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
Axios compromise traced to social engineering, showing how attacks on maintainers can bypass controls and expose the broader software supply chain.

Security News
Node.js has paused its bug bounty program after funding ended, removing payouts for vulnerability reports but keeping its security process unchanged.

Security News
The Axios compromise shows how time-dependent dependency resolution makes exposure harder to detect and contain.