npm package: @lumir-company/editor
Description: Image-only BlockNote rich text editor with S3 upload, customizable filename transforms, UUID support, and loading spinner
Latest version: 0.4.13

# LumirEditor

**이미지 전용** BlockNote 기반 Rich Text 에디터

[![npm version](https://img.shields.io/npm/v/@lumir-company/editor.svg)](https://www.npmjs.com/package/@lumir-company/editor)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

> 이미지 업로드에 최적화된 경량 에디터. S3 업로드, 파일명 커스터마이징, 로딩 스피너 내장.

---

## 목차

- [특징](#특징)
- [빠른 시작](#빠른-시작)
- [이미지 업로드](#이미지-업로드)
  - [S3 업로드 설정](#1-s3-업로드-권장)
  - [파일명 커스터마이징](#파일명-커스터마이징)
  - [커스텀 업로더](#2-커스텀-업로더)
- [동영상 업로드 및 임베딩](#동영상-업로드-및-임베딩)
  - [업로드 진행률 표시](#업로드-진행률-표시-이미지동영상-공통)
- [이미지·동영상 업로드 상세 가이드](#이미지동영상-업로드-상세-가이드)
- [이미지·비디오 삭제](#이미지비디오-삭제)
- [HTML 미리보기](#html-미리보기)
- [Placeholder](#placeholder)
- [링크 프리뷰](#링크-프리뷰)
- [Props API](#props-api)
- [사용 예제](#사용-예제)
- [스타일링](#스타일링)
- [트러블슈팅](#트러블슈팅)

---

## 특징

| 특징                    | 설명                                                                             |
| ----------------------- | -------------------------------------------------------------------------------- |
| **이미지 전용**         | 이미지 업로드/드래그앤드롭 기본 지원 (비디오는 `allowVideoUpload`로 옵션 활성화) |
| **HTML 미리보기**       | HTML 파일을 드래그 앤 드롭하여 iframe으로 미리보기                               |
| **S3 연동**             | Presigned URL 기반 S3 업로드 내장                                                |
| **파일명 커스터마이징** | 업로드 파일명 변경 콜백 + UUID 자동 추가 지원                                    |
| **로딩 스피너**         | 이미지 업로드 중 자동 스피너 표시                                                |
| **성능 최적화**         | 애니메이션 비활성화로 빠른 렌더링                                                |
| **TypeScript**          | 완전한 타입 안전성                                                               |
| **테마 지원**           | 라이트/다크 테마 및 커스텀 테마                                                  |

### 지원 이미지 형식

```
PNG, JPEG/JPG, GIF, WebP, BMP
```

---

## 빠른 시작

### 1. 설치

```bash
npm install @lumir-company/editor
# 또는
yarn add @lumir-company/editor
```

**필수 Peer Dependencies:**

- `react` >= 18.0.0
- `react-dom` >= 18.0.0

### 2. 기본 사용

```tsx
import { LumirEditor } from "@lumir-company/editor";
import "@lumir-company/editor/style.css"; // 필수!

export default function App() {
  return (
    <div className="w-full h-[500px]">
      <LumirEditor onContentChange={(blocks) => console.log(blocks)} />
    </div>
  );
}
```

> **중요**: `style.css`를 임포트하지 않으면 에디터가 정상 작동하지 않습니다.

### 3. Next.js에서 사용

```tsx
"use client";

import dynamic from "next/dynamic";
import "@lumir-company/editor/style.css";

// SSR 비활성화 필수
const LumirEditor = dynamic(
  () =>
    import("@lumir-company/editor").then((m) => ({ default: m.LumirEditor })),
  { ssr: false },
);

export default function EditorPage() {
  return (
    <div className="h-[500px]">
      <LumirEditor />
    </div>
  );
}
```

---

## 이미지 업로드

### 1. S3 업로드 (권장)

Presigned URL을 사용한 안전한 S3 업로드 방식입니다.

```tsx
<LumirEditor
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "blog/images",
  }}
/>
```

#### S3 파일 저장 경로

```
{env}/{path}/{filename}

예시:
production/blog/images/my-photo.png
```

#### API 엔드포인트 응답 형식

서버는 다음 형식으로 응답해야 합니다:

```json
{
  "presignedUrl": "https://s3.amazonaws.com/bucket/upload-url",
  "publicUrl": "https://cdn.example.com/production/blog/images/my-photo.png"
}
```

클라이언트는 `apiEndpoint?key={파일키}&contentType={MIME}` 형태로 GET 요청을 보내고, 서버는 위 형식으로 JSON을 반환하면 됩니다.

#### S3 Presigned URL API 구현 예시

**Next.js (App Router)**

파일: `app/api/s3/presigned/route.ts`

```typescript
import { NextRequest, NextResponse } from "next/server";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = 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 GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const key = searchParams.get("key");
  const contentType = searchParams.get("contentType");

  if (!key) {
    return NextResponse.json({ error: "key is required" }, { status: 400 });
  }

  const command = new PutObjectCommand({
    Bucket: process.env.AWS_S3_BUCKET!,
    Key: key,
    ContentType: contentType || "application/octet-stream",
  });

  const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 60 });
  const publicUrl = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;

  return NextResponse.json({ presignedUrl, publicUrl, key });
}
```

필요한 환경 변수: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET`

**Next.js가 아닌 프로젝트에서 사용하기**

동일하게 **GET** 요청으로 `key`, `contentType` 쿼리 파라미터를 받아 `presignedUrl`, `publicUrl`을 JSON으로 반환하는 엔드포인트를 구현하면 됩니다.

- **Express (Node.js)**

```javascript
const express = require("express");
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");

const s3 = new S3Client({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  },
});

app.get("/api/s3/presigned", async (req, res) => {
  const key = req.query.key;
  const contentType = req.query.contentType || "application/octet-stream";

  if (!key) {
    return res.status(400).json({ error: "key is required" });
  }

  const command = new PutObjectCommand({
    Bucket: process.env.AWS_S3_BUCKET,
    Key: key,
    ContentType: contentType,
  });

  const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 60 });
  const publicUrl = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;

  res.json({ presignedUrl, publicUrl, key });
});
```

에디터 사용 시 `apiEndpoint`만 해당 서버 주소로 맞추면 됩니다.

```tsx
<LumirEditor
  s3Upload={{
    apiEndpoint: "https://api.myapp.com/api/s3/presigned",
    env: "production",
    path: "uploads",
  }}
/>
```

- **Remix / SvelteKit / 기타 프레임워크**  
  GET 라우트에서 `key`, `contentType`을 받아 `@aws-sdk/client-s3`의 `PutObjectCommand`와 `@aws-sdk/s3-request-presigner`의 `getSignedUrl`로 presigned URL을 생성한 뒤, `{ presignedUrl, publicUrl, key }` 형태로 JSON 응답하면 동일하게 사용할 수 있습니다. CORS가 필요한 경우 해당 도메인을 허용해 두세요.

---

### 파일명 커스터마이징

여러 이미지를 동시에 업로드할 때 파일명 중복을 방지하고 관리하기 쉽게 만드는 기능입니다.

> **참고**: 기본적으로 확장자는 자동으로 붙습니다. `preserveExtension: false`로 설정하면 확장자를 붙이지 않습니다.

#### 옵션 1: UUID 자동 추가

```tsx
<LumirEditor
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "uploads",
    appendUUID: true, // 파일명 뒤에 UUID 자동 추가
  }}
/>
```

**결과:**

```
원본: photo.png
업로드: photo_550e8400-e29b-41d4-a716-446655440000.png
```

#### 옵션 2: 파일명 변환 콜백

```tsx
<LumirEditor
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "uploads",
    fileNameTransform: (nameWithoutExt, file) => {
      // nameWithoutExt는 확장자가 제거된 파일명 (예: "photo")
      // 확장자는 자동으로 붙습니다
      const userId = getCurrentUserId();
      return `${userId}_${nameWithoutExt}`;
    },
  }}
/>
```

**결과:**

```
원본: photo.png
→ nameWithoutExt: "photo"
→ 변환 후: "user123_photo"
→ 최종: user123_photo.png
```

#### 옵션 3: 조합 사용 (권장)

```tsx
<LumirEditor
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "uploads",
    fileNameTransform: (nameWithoutExt) => `user123_${nameWithoutExt}`,
    appendUUID: true, // 변환 후 UUID 추가
  }}
/>
```

**결과:**

```
원본: photo.png
→ nameWithoutExt: "photo"
1. fileNameTransform 적용: "user123_photo"
2. appendUUID 적용: "user123_photo_550e8400-e29b-41d4"
3. 확장자 붙이기: user123_photo_550e8400-e29b-41d4.png
```

#### 실전 예제: 타임스탬프 + UUID

```tsx
function MyEditor() {
  return (
    <LumirEditor
      s3Upload={{
        apiEndpoint: "/api/s3/presigned",
        env: "production",
        path: "uploads",
        fileNameTransform: (nameWithoutExt, file) => {
          // nameWithoutExt는 이미 확장자가 제거됨
          const timestamp = new Date().toISOString().split("T")[0]; // 2024-01-15
          return `${timestamp}_${nameWithoutExt}`;
        },
        appendUUID: true,
      }}
    />
  );
}
```

**결과:**

```
원본: photo.png
→ nameWithoutExt: "photo"
1. fileNameTransform: "2024-01-15_photo"
2. appendUUID: "2024-01-15_photo_550e8400-e29b-41d4"
3. 확장자 붙이기: 2024-01-15_photo_550e8400-e29b-41d4.png
```

#### 옵션 4: 확장자 제거 (preserveExtension: false)

```tsx
<LumirEditor
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "uploads",
    fileNameTransform: (nameWithoutExt) => `${nameWithoutExt}_custom`,
    preserveExtension: false, // 확장자 안 붙임
  }}
/>
```

**결과:**

```
원본: photo.png
→ nameWithoutExt: "photo"
→ 변환 후: "photo_custom"
→ 최종: photo_custom (확장자 없음)
```

**사용 사례**: WebP 변환 등 서버에서 확장자를 변경하는 경우

```tsx
fileNameTransform: (nameWithoutExt) => `${nameWithoutExt}.webp`,
preserveExtension: false,
```

---

### 2. 커스텀 업로더

자체 업로드 로직을 사용할 때:

```tsx
<LumirEditor
  uploadFile={async (file) => {
    const formData = new FormData();
    formData.append("image", file);

    const response = await fetch("/api/upload", {
      method: "POST",
      body: formData,
    });

    const { url } = await response.json();
    return url; // 업로드된 이미지 URL 반환
  }}
/>
```

### 3. 헬퍼 함수 사용

```tsx
import { createS3Uploader } from "@lumir-company/editor";

const s3Uploader = createS3Uploader({
  apiEndpoint: "/api/s3/presigned",
  env: "production",
  path: "images",
  appendUUID: true,
});

// 에디터에 적용
<LumirEditor uploadFile={s3Uploader} />;

// 또는 별도로 사용
const imageUrl = await s3Uploader(imageFile);
```

### 업로드 우선순위

1. `uploadFile` prop이 있으면 우선 사용
2. `uploadFile` 없고 `s3Upload`가 있으면 S3 업로드 사용
3. 둘 다 없으면 업로드 실패

---

## 동영상 업로드 및 임베딩

`allowVideoUpload={true}`로 설정하면 동영상 업로드와 에디터 내 재생이 가능합니다. S3/`uploadFile`은 이미지와 동일한 설정을 사용하며, 동영상은 최대 100MB까지 허용됩니다.

### 동영상 업로드 활성화

```tsx
<LumirEditor
  allowVideoUpload={true}
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "videos",
    appendUUID: true,
  }}
/>
```

- **지원 형식**: MP4, WebM, OGG
- **삽입 경로**: 붙여넣기, 드래그 앤 드롭, 슬래시 메뉴("Video"), FloatingMenu 이미지/동영상 버튼

### 업로드 진행률 표시 (이미지·동영상 공통)

S3 업로드 시 `s3Upload.onProgress` 콜백을 지정하면 업로드 진행률(0~100%)을 받을 수 있습니다. 에디터는 내부적으로 이 값을 사용해 업로드 중 툴바에 **"n%"** 를 표시합니다. 동영상처럼 대용량 파일은 브라우저가 `progress` 이벤트를 자주 보내지 않을 수 있어, 내부적으로 **보간 로직**을 적용해 0→100만 보이지 않고 중간 진행률이 부드럽게 갱신되도록 했습니다.

```tsx
<LumirEditor
  allowVideoUpload={true}
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "videos",
    appendUUID: true,
    onProgress: (percent) => {
      console.log(`업로드 진행률: ${percent}%`);
      // 에디터 기본 UI에 이미 표시되며, 필요 시 자체 프로그레스 바 등에 연동 가능
    },
  }}
/>
```

- **동작**: S3 PUT 요청 시에만 호출됩니다. Presigned URL 요청 단계에서는 호출되지 않습니다.
- **표시**: `onProgress`를 넘기면 에디터 툴바에 `n%`가 자동 표시되며, 업로드 완료 시 숨겨집니다.

### 데이터 내부 동영상 임베딩

동영상 블록은 `initialContent` / `onContentChange`에 포함됩니다. 저장 시 `{ type: "video", props: { url: "..." } }` 형태로 블록이 유지됩니다.

- **재생**: 화면에서 동영상을 재생하려면 `allowVideoUpload={true}`로 두어야 합니다. 이렇게 해야 video 확장이 활성화되어 BlockNote 기본 플레이어가 렌더링됩니다.
- `allowVideoUpload={false}`인 상태에서 initialContent에 video 블록만 넣으면 데이터는 보존되지만, 재생 UI는 비활성화된 확장 때문에 표시되지 않을 수 있습니다.
- **지원 URL**: 비디오 블록의 `url`은 **직접 재생 가능한 비디오 파일 URL**만 지원합니다(예: S3에 업로드된 `.mp4`, `.webm`, `.ogg`). YouTube·Vimeo 등 스트리밍 페이지 URL(`youtube.com/watch?v=...` 등)은 `<video>` 요소의 `src`로 재생되지 않으므로, 해당 링크를 video 블록 URL로 넣으면 재생되지 않습니다. YouTube 임베드가 필요하면 별도 embed 블록 또는 iframe 삽입 방식을 고려해야 합니다.

---

## 이미지·동영상 업로드 상세 가이드

이미지와 동영상 업로드 기능을 함께 쓰는 방법을 단계별로 정리했습니다.

### 1. 개요

| 구분            | 이미지                          | 동영상                                     |
| --------------- | ------------------------------- | ------------------------------------------ |
| **기본 동작**   | 업로드 항상 사용 가능 (설정 시) | `allowVideoUpload={true}`일 때만 사용 가능 |
| **최대 용량**   | 10MB                            | 100MB                                      |
| **업로드 설정** | `s3Upload` 또는 `uploadFile`    | 이미지와 동일한 설정 공유                  |

- 이미지만 쓸 때: `s3Upload` 또는 `uploadFile`만 설정하면 됩니다.
- 이미지 + 동영상: 위 설정에 더해 `allowVideoUpload={true}`를 넣습니다.

### 2. 지원 형식 및 제한

**이미지**

- **MIME**: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/bmp`
- **확장자**: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp`
- **용량**: 기본 최대 10MB. `maxImageFileSize`(바이트)로 변경 가능.
- **제외**: SVG (XSS 방지로 업로드 불가)

**동영상** (`allowVideoUpload={true}`일 때)

- **MIME**: `video/mp4`, `video/webm`, `video/ogg`, `video/quicktime`
- **확장자**: `.mp4`, `.webm`, `.ogg`, `.mov`
- **용량**: 기본 최대 100MB. `maxVideoFileSize`(바이트)로 변경 가능.

**업로드 타임아웃**: S3 업로드 시 PUT 요청 타임아웃은 `s3Upload.uploadTimeoutMs`로 설정합니다. 미설정 시 120초(120000ms)가 적용됩니다.

**용량 및 타임아웃 사용자 설정**

이미지·동영상 최대 용량과 S3 PUT 타임아웃을 props로 변경할 수 있습니다. 미설정 시 기본값(이미지 10MB, 동영상 100MB, 타임아웃 120초)이 적용됩니다.

```tsx
<LumirEditor
  allowVideoUpload={true}
  maxImageFileSize={5 * 1024 * 1024} // 5MB
  maxVideoFileSize={200 * 1024 * 1024} // 200MB
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "uploads",
    uploadTimeoutMs: 180000, // 180초 (대용량 동영상 시 연장)
  }}
/>
```

### 3. 삽입 경로 (공통)

이미지·동영상 모두 아래 경로로 삽입할 수 있습니다.

| 경로               | 설명                                                              |
| ------------------ | ----------------------------------------------------------------- |
| **붙여넣기**       | 클립보드 이미지/동영상 → Ctrl+V (또는 Cmd+V)                      |
| **드래그 앤 드롭** | 파일을 에디터 영역으로 끌어다 놓기                                |
| **슬래시 메뉴**    | `/` 입력 후 "Image" 또는 "Video"(동영상 허용 시) 선택 → 파일 선택 |
| **FloatingMenu**   | 툴바의 이미지/동영상 버튼 클릭 → 파일 선택                        |

동영상은 `allowVideoUpload={true}`일 때만 슬래시 메뉴에 "Video"가 보이고, FloatingMenu에서도 동영상 파일을 선택할 수 있습니다.

### 4. 설정 방법 요약

**우선순위**

1. `uploadFile` prop이 있으면 → 해당 함수로 업로드 (이미지·동영상 동일)
2. `uploadFile` 없고 `s3Upload`가 있으면 → Presigned URL 기반 S3 업로드
3. 둘 다 없으면 → 업로드 시 에러 (에디터는 동작하지만 파일 삽입 불가)

**이미지만 사용하는 경우**

```tsx
<LumirEditor
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "images",
    appendUUID: true,
  }}
  onContentChange={(blocks) => setContent(blocks)}
/>
```

**이미지 + 동영상 사용하는 경우**

```tsx
<LumirEditor
  allowVideoUpload={true}
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "images",
    appendUUID: true,
  }}
  onContentChange={(blocks) => setContent(blocks)}
/>
```

동영상은 같은 `s3Upload`로 업로드됩니다. 서버에서 이미지와 동영상을 다른 경로에 두고 싶다면 아래처럼 `fileNameTransform`으로 prefix를 분리하면 됩니다.

**이미지·동영상 업로드 경로 분리 (fileNameTransform)**

`fileNameTransform`의 두 번째 인자 `file`로 이미지/동영상을 구분해, 파일명 앞에 폴더 prefix를 붙이면 됩니다. 최종 S3 키는 `{env}/{path}/{filename}` 이므로, `filename`에 `images/...` / `videos/...` 를 넣으면 경로가 나뉩니다.

```tsx
<LumirEditor
  allowVideoUpload={true}
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "uploads", // 공통 상위 경로
    appendUUID: true,
    fileNameTransform: (nameWithoutExt, file) => {
      const isVideo = file.type.startsWith("video/");
      return `${isVideo ? "videos" : "images"}/${nameWithoutExt}`;
    },
  }}
/>
```

결과 예: 이미지 → `production/uploads/images/photo_abc123.png`, 동영상 → `production/uploads/videos/clip_def456.mp4`

**커스텀 업로더로 이미지·동영상 통합**

```tsx
<LumirEditor
  allowVideoUpload={true}
  uploadFile={async (file) => {
    const formData = new FormData();
    formData.append("file", file);
    const res = await fetch("/api/upload", { method: "POST", body: formData });
    const { url } = await res.json();
    return url;
  }}
/>
```

`uploadFile`은 이미지/동영상 구분 없이 `File`을 받아 업로드 후 **공개 URL 문자열**을 반환하면 됩니다.

### 5. 저장 데이터 구조

`onContentChange` / `initialContent`에서 사용하는 블록 형태입니다.

**이미지 블록**

```json
{
  "type": "image",
  "props": {
    "url": "https://your-cdn.com/images/photo_xxx.png",
    "caption": "",
    "previewWidth": 512
  },
  "content": [],
  "children": []
}
```

**동영상 블록**

```json
{
  "type": "video",
  "props": {
    "url": "https://your-cdn.com/videos/clip_xxx.mp4"
  },
  "content": [],
  "children": []
}
```

`url`은 반드시 **브라우저에서 직접 재생 가능한 URL**이어야 합니다. 동영상은 YouTube/Vimeo 링크가 아니라, 업로드 후 받은 `.mp4` 등 직접 재생 URL만 지원합니다.

### 6. 삭제 시 콜백

이미지·동영상 블록을 에디터에서 삭제하면 `onImageDelete`가 호출됩니다. 인자는 삭제된 미디어의 URL입니다.

```tsx
<LumirEditor
  s3Upload={{ ... }}
  allowVideoUpload={true}
  onImageDelete={(url) => {
    // url은 이미지 또는 동영상 URL
    console.log("삭제됨:", url);
    // S3/스토리지에서 삭제 API 호출
  }}
/>
```

Undo로 블록을 복원해도 이미 삭제 API를 호출했다면 서버 상태와 불일치할 수 있으므로, 지연 삭제(예: 5분 후 삭제) 패턴을 권장합니다. 자세한 예시는 [이미지·비디오 삭제](#이미지비디오-삭제)를 참고하세요.

### 7. 에러 처리

- **지원하지 않는 형식**: 업로드 시 `LumirEditorError`가 발생할 수 있으며, `onError`로 처리할 수 있습니다.
- **용량 초과**: 기본 한도(이미지 10MB·동영상 100MB)를 넘으면 업로드가 거부됩니다. `maxImageFileSize`, `maxVideoFileSize`로 한도를 변경할 수 있습니다.
- **업로드 실패**: `uploadFile` 또는 S3 업로드에서 예외가 나면 해당 파일만 삽입되지 않고, 콘솔에 경고가 출력됩니다.

```tsx
<LumirEditor
  s3Upload={{ ... }}
  onError={(err) => {
    console.error("에디터 에러:", err);
    // 토스트 등으로 사용자 알림
  }}
/>
```

---

## 이미지·비디오 삭제

에디터에서 **이미지 또는 비디오**가 삭제될 때 S3 등 외부 스토리지에서도 자동으로 삭제하고 싶다면 `onImageDelete` 콜백을 사용하세요. 이미지 블록과 비디오 블록 삭제 시 모두 호출됩니다.

### 기본 사용

```tsx
<LumirEditor
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "images",
  }}
  onImageDelete={(imageUrl) => {
    console.log("미디어(이미지/비디오) 삭제됨:", imageUrl);
    // S3에서 삭제 로직 구현
  }}
/>
```

### 권장: 지연 삭제 (Undo/Redo 대응)

Undo로 이미지·비디오를 복원할 수 있도록 **지연 삭제**를 권장합니다.

```tsx
"use client";

import { useState, useRef, useCallback } from "react";

function Editor() {
  const pendingDeletes = useRef(new Map());

  const handleImageDelete = useCallback((imageUrl: string) => {
    // 이미 예약된 삭제가 있으면 무시
    if (pendingDeletes.current.has(imageUrl)) return;

    // 5분 후 삭제 예약
    const timeoutId = setTimeout(
      async () => {
        pendingDeletes.current.delete(imageUrl);

        // S3에서 실제 삭제
        await fetch(`/api/s3/delete?url=${encodeURIComponent(imageUrl)}`, {
          method: "DELETE",
        });
      },
      5 * 60 * 1000,
    ); // 5분

    pendingDeletes.current.set(imageUrl, timeoutId);
  }, []);

  return (
    <LumirEditor
      s3Upload={
        {
          /* ... */
        }
      }
      onImageDelete={handleImageDelete}
    />
  );
}
```

### S3 삭제 API 예시

> **참고**: `onImageDelete`는 **프레임워크 독립적**이며, 이미지와 비디오 삭제 시 모두 호출됩니다. 아래는 각 환경별 구현 예시입니다.

#### Next.js API Route

**파일**: `app/api/s3/delete/route.ts`

```typescript
import { NextRequest, NextResponse } from "next/server";
import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";

const s3 = 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 DELETE(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const imageUrl = searchParams.get("url");

  if (!imageUrl) {
    return NextResponse.json({ error: "url is required" }, { status: 400 });
  }

  // URL에서 S3 키 추출
  const key = extractKeyFromUrl(imageUrl);

  await s3.send(
    new DeleteObjectCommand({
      Bucket: process.env.AWS_S3_BUCKET!,
      Key: key,
    }),
  );

  return NextResponse.json({ success: true });
}

function extractKeyFromUrl(url: string): string {
  const urlObj = new URL(url);
  return decodeURIComponent(urlObj.pathname.slice(1));
}
```

**클라이언트 구현**:

```tsx
const handleImageDelete = (imageUrl: string) => {
  fetch(`/api/s3/delete?url=${encodeURIComponent(imageUrl)}`, {
    method: "DELETE",
  });
};

<LumirEditor onImageDelete={handleImageDelete} />;
```

#### React + Express

**서버** (`server.js`):

```javascript
app.delete("/api/images", async (req, res) => {
  const { imageUrl } = req.body;
  const key = extractKeyFromS3Url(imageUrl);

  await s3Client.send(
    new DeleteObjectCommand({
      Bucket: process.env.S3_BUCKET,
      Key: key,
    }),
  );

  res.json({ success: true });
});
```

**클라이언트**:

```tsx
const handleImageDelete = async (imageUrl: string) => {
  await fetch("https://api.myapp.com/api/images", {
    method: "DELETE",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ imageUrl }),
  });
};

<LumirEditor onImageDelete={handleImageDelete} />;
```

#### React Native + Firebase Storage

```tsx
import storage from "@react-native-firebase/storage";

const handleImageDelete = async (imageUrl: string) => {
  const ref = storage().refFromURL(imageUrl);
  await ref.delete();
};

<LumirEditor onImageDelete={handleImageDelete} />;
```

#### Vue + Axios + FastAPI

```typescript
const handleImageDelete = async (imageUrl: string) => {
  await axios.delete("https://api.myapp.com/v1/images", {
    data: { imageUrl },
  });
};
```

### 주의사항

| 항목            | 설명                                          |
| --------------- | --------------------------------------------- |
| **Undo/Redo**   | 지연 삭제로 복원 가능하게 구현 (권장: 5-10분) |
| **권한 검증**   | 프로덕션에서는 인증/인가 필수                 |
| **참조 카운트** | 같은 이미지를 여러 문서에서 사용하는지 확인   |
| **삭제 로그**   | 감사 추적을 위한 삭제 기록 저장 권장          |

---

## HTML 미리보기

LumirEditor는 HTML 파일을 iframe을 사용하여 미리보기할 수 있는 커스텀 블록을 제공합니다. 편집 불가능한 순수 미리보기 기능으로, HTML 문서를 안전하게 표시할 수 있습니다.

### 사용 방법

#### 1. 드래그 앤 드롭

HTML 파일(`.html`, `.htm`)을 에디터에 드래그 앤 드롭하면 자동으로 iframe 미리보기 블록이 삽입됩니다.

```tsx
<LumirEditor />
```

- **지원 파일 형식**: `.html`, `.htm`
- **특징**:
  - 편집 불가능한 순수 미리보기
  - 접기/펼치기 기능
  - 안전한 sandbox 처리 (`allow-same-origin`, JavaScript 실행 비활성화)
  - 파일명 표시

#### 2. 슬래시 메뉴

에디터에서 `/`를 입력하고 "HTML Preview"를 선택하면 예제 HTML 미리보기 블록이 삽입됩니다.

```
/ → HTML Preview
```

### 특징

- **iframe 기반**: HTML 문서를 독립된 iframe에서 안전하게 렌더링
- **Sandbox 보안**: `sandbox="allow-same-origin"` 속성으로 보안 강화 (JavaScript 실행 의도적 비활성화)
- **접기/펼치기**: 헤더 클릭으로 미리보기 영역 토글
- **드래그 리사이즈**: 하단 핸들을 드래그하여 높이 조절 가능 (100px ~ 1200px)
- **새 창 열기**: HTML 문서를 새 창에서 전체 화면으로 확인
- **다운로드**: HTML 파일로 다운로드
- **편집 불가**: 순수 미리보기 전용

### 사용 예제

```tsx
import { LumirEditor } from "@lumir-company/editor";
import "@lumir-company/editor/style.css";

function App() {
  return (
    <div className="w-full h-[600px]">
      <LumirEditor
        onContentChange={(blocks) => {
          // HTML 미리보기 블록도 일반 블록과 동일하게 처리됨
          console.log(blocks);
        }}
      />
    </div>
  );
}
```

### 프로그래밍 방식으로 블록 삽입

```tsx
import { HtmlPreview } from "@lumir-company/editor";

// 에디터 인스턴스에서 직접 블록 삽입
editor.insertBlocks([
  {
    type: "htmlPreview",
    props: {
      htmlContent: "<h1>Hello World</h1><p>This is HTML content</p>",
      fileName: "example.html",
      height: "400px",
    },
  },
]);
```

### 주의사항

- HTML 내용은 iframe의 `sandbox="allow-same-origin"` 속성으로 보안이 강화되어 있습니다
- **JavaScript는 의도적으로 비활성화**되어 있습니다 (보안상 이유)
- 외부 리소스(CSS, JS, 이미지 등)는 상대 경로가 작동하지 않을 수 있습니다
- 인라인 CSS 스타일을 권장합니다

---

## Placeholder

에디터가 비어있을 때 사용자에게 안내 텍스트를 표시합니다.

### 사용 방법

```tsx
<LumirEditor placeholder="내용을 입력하세요..." />
```

- 빈 블록에 연한 색상으로 안내 텍스트가 표시됩니다
- 사용자가 입력을 시작하면 자동으로 사라집니다
- 모든 빈 블록(첫 번째 블록 포함)에 동일한 텍스트가 적용됩니다

---

## 링크 프리뷰

URL을 붙여넣거나 슬래시 메뉴에서 선택하면 Open Graph 카드를 표시합니다.

> **서버 사이드 필수**: 링크 프리뷰는 외부 사이트의 OG 메타데이터를 가져오기 위해 **서버 사이드 API 라우트**가 필요합니다. 브라우저의 CORS 정책으로 인해 클라이언트에서 직접 외부 HTML을 가져올 수 없습니다.

### 사용 조건

| 조건                       | 설명                                                                       |
| -------------------------- | -------------------------------------------------------------------------- |
| **서버 환경**              | Next.js, Remix, SvelteKit 등 서버 사이드 라우팅을 지원하는 프레임워크 필요 |
| **API 라우트**             | 패키지 내장 핸들러를 re-export하는 1줄짜리 파일 필요                       |
| **순수 React (CRA, Vite)** | 별도 백엔드 서버 없이는 사용 불가                                          |

### 설정 방법 (Next.js App Router)

**1단계: API 라우트 생성** (1줄)

```ts
// src/app/api/link-preview/route.ts
export { linkPreviewHandler as GET } from "@lumir-company/editor/api/link-preview";
```

패키지에 내장된 핸들러를 re-export하므로 별도 로직 작성이 불필요합니다.

**2단계: 에디터에 linkPreview prop 설정**

```tsx
<LumirEditor linkPreview={{ apiEndpoint: "/api/link-preview" }} />
```

### 설정 방법 (Remix / SvelteKit)

패키지의 `GET` 핸들러는 표준 Web API(`Request`/`Response`)를 사용하므로 Remix, SvelteKit 등에서도 동일하게 사용 가능합니다.

```ts
// Remix: app/routes/api.link-preview.ts
export { linkPreviewHandler as loader } from "@lumir-company/editor/api/link-preview";
```

### 설정 방법 (Express / Fastify 등 커스텀 서버)

패키지에서 `fetchUrlMetadata`와 `parseMetaTags`를 import하여 직접 라우트를 구현할 수 있습니다.

```ts
import { fetchUrlMetadata } from "@lumir-company/editor/api/link-preview";

app.get("/api/link-preview", async (req, res) => {
  const url = req.query.url as string;
  if (!url) return res.status(400).json({ error: "url required" });

  try {
    const metadata = await fetchUrlMetadata(url);
    res.json(metadata);
  } catch {
    res.status(500).json({ error: "Failed to fetch metadata" });
  }
});
```

### 주요 기능

- URL 붙여넣기 시 자동 링크 프리뷰 블록 생성
- 슬래시 메뉴(`/`)에서 Link Preview 항목 선택
- 드래그 리사이즈 (좌우 너비, 하단 이미지 높이)
- 텍스트 링크를 링크 프리뷰로 전환 (링크 툴바 버튼)
- 메타데이터에 이미지 없으면 이미지 영역 자동 생략
- 에러 카드 클릭 시 링크 이동

### 내장 API 핸들러 export 목록

`@lumir-company/editor/api/link-preview`에서 export되는 항목:

| Export               | 타입                                              | 설명                                             |
| -------------------- | ------------------------------------------------- | ------------------------------------------------ |
| `linkPreviewHandler` | `(request: Request) => Promise<Response>`         | 링크 프리뷰 메타데이터 조회 핸들러 (re-export용) |
| `fetchUrlMetadata`   | `(url: string) => Promise<LinkMetadata>`          | 서버에서 직접 메타데이터 조회                    |
| `parseMetaTags`      | `(html: string, baseUrl: string) => LinkMetadata` | HTML에서 OG 메타데이터 파싱                      |
| `LinkMetadata`       | `interface`                                       | 메타데이터 타입 정의                             |

---

## Props API

### 핵심 Props

| Prop               | 타입                                | 기본값      | 설명                                      |
| ------------------ | ----------------------------------- | ----------- | ----------------------------------------- |
| `s3Upload`         | `S3UploaderConfig`                  | `undefined` | S3 업로드 설정                            |
| `uploadFile`       | `(file: File) => Promise<string>`   | `undefined` | 커스텀 업로드 함수                        |
| `onContentChange`  | `(blocks) => void`                  | `undefined` | 콘텐츠 변경 콜백                          |
| `onImageDelete`    | `(imageUrl: string) => void`        | `undefined` | 이미지·비디오 삭제 시 콜백                |
| `onError`          | `(error: LumirEditorError) => void` | `undefined` | 에러 발생 시 콜백                         |
| `initialContent`   | `Block[] \| string`                 | `undefined` | 초기 콘텐츠                               |
| `editable`         | `boolean`                           | `true`      | 편집 가능 여부                            |
| `placeholder`      | `string`                            | `undefined` | 빈 블록 안내 텍스트                       |
| `linkPreview`      | `{ apiEndpoint: string }`           | `undefined` | 링크 프리뷰 설정                          |
| `theme`            | `"light" \| "dark"`                 | `"light"`   | 테마                                      |
| `className`        | `string`                            | `""`        | CSS 클래스                                |
| `maxImageFileSize` | `number`                            | `undefined` | 이미지 최대 용량(바이트). 미설정 시 10MB  |
| `maxVideoFileSize` | `number`                            | `undefined` | 동영상 최대 용량(바이트). 미설정 시 100MB |

### S3UploaderConfig

```tsx
interface S3UploaderConfig {
  // 필수
  apiEndpoint: string; // Presigned URL API 엔드포인트
  env: "development" | "production";
  path: string; // S3 저장 경로

  // 선택 (파일명 커스터마이징)
  fileNameTransform?: (nameWithoutExt: string, file: File) => string; // 확장자 제외한 이름 변환
  appendUUID?: boolean; // true: 파일명 뒤에 UUID 추가 (확장자 앞에 삽입)
  preserveExtension?: boolean; // false: 확장자를 붙이지 않음 (기본: true)

  // 선택 (업로드 동작)
  onProgress?: (percent: number) => void; // 업로드 진행률 0~100 콜백 (S3 PUT 시만 호출, 중간 진행률 보간 지원)
  uploadTimeoutMs?: number; // PUT 타임아웃(ms). 미설정 시 120000(120초). 대용량 동영상 시 연장 권장
  maxRetries?: number; // PUT 실패 시 재시도 횟수. 기본 2(최대 3회 시도)
}
```

### 전체 Props

<details>
<summary>전체 Props 보기</summary>

```tsx
interface LumirEditorProps {
  // === 에디터 설정 ===
  initialContent?: DefaultPartialBlock[] | string; // 초기 콘텐츠 (블록 배열 또는 JSON 문자열)
  initialEmptyBlocks?: number; // 초기 빈 블록 개수 (기본: 3)
  placeholder?: string; // 빈 블록에 표시할 안내 텍스트 (예: "내용을 입력하세요...")
  uploadFile?: (file: File) => Promise<string>; // 커스텀 파일 업로드 함수
  s3Upload?: {
    apiEndpoint: string;
    env: "development" | "production";
    path: string;
    fileNameTransform?: (nameWithoutExt: string, file: File) => string; // 확장자 제외한 이름 변환
    appendUUID?: boolean; // UUID 자동 추가 (확장자 앞)
    preserveExtension?: boolean; // 확장자 자동 붙이기 (기본: true)
    onProgress?: (percent: number) => void; // 업로드 진행률 0~100 (이미지·동영상 공통, 중간 진행률 보간)
    uploadTimeoutMs?: number; // PUT 타임아웃(ms). 기본 120000
    maxRetries?: number; // PUT 재시도 횟수. 기본 2
  };

  // === 콜백 ===
  onContentChange?: (blocks: DefaultPartialBlock[]) => void; // 콘텐츠 변경 시 호출
  onImageDelete?: (imageUrl: string) => void; // 이미지·비디오 삭제 시 호출 (S3 삭제 등)
  onSelectionChange?: () => void; // 선택 영역 변경 시 호출
  onError?: (error: LumirEditorError) => void; // 에러 발생 시 호출

  // 기능 설정
  tables?: TableConfig; // 테이블 기능 설정 (splitCells, cellBackgroundColor 등)
  heading?: { levels?: (1 | 2 | 3 | 4 | 5 | 6)[] }; // 헤딩 레벨 설정 (기본: [1,2,3,4,5,6])
  defaultStyles?: boolean; // 기본 스타일 활성화 (기본: true)
  disableExtensions?: string[]; // 비활성화할 확장 기능 목록
  tabBehavior?: "prefer-navigate-ui" | "prefer-indent"; // 탭 키 동작 (기본: "prefer-navigate-ui")
  trailingBlock?: boolean; // 마지막에 빈 블록 자동 추가 (기본: true)

  // === UI 설정 ===
  editable?: boolean; // 편집 가능 여부 (기본: true)
  theme?: "light" | "dark" | ThemeObject; // 에디터 테마 (기본: "light")
  formattingToolbar?: boolean; // 서식 툴바 표시 (기본: true)
  linkToolbar?: boolean; // 링크 툴바 표시 (기본: true)
  sideMenu?: boolean; // 사이드 메뉴 표시 (기본: true)
  sideMenuAddButton?: boolean; // 사이드 메뉴 + 버튼 표시 (기본: false, 드래그 핸들만 표시)
  emojiPicker?: boolean; // 이모지 선택기 표시 (기본: true)
  filePanel?: boolean; // 파일 패널 표시 (기본: true)
  tableHandles?: boolean; // 테이블 핸들 표시 (기본: true)
  className?: string; // 컨테이너 CSS 클래스

  // === 링크 프리뷰 설정 ===
  linkPreview?: {
    apiEndpoint: string; // 링크 메타데이터를 가져올 API 엔드포인트 (예: "/api/link-preview")
  };

  // 미디어 업로드 허용 여부 (기본: 모두 비활성)
  allowVideoUpload?: boolean; // 비디오 업로드 허용 (기본: false)
  allowAudioUpload?: boolean; // 오디오 업로드 허용 (기본: false)
  allowFileUpload?: boolean; // 일반 파일 업로드 허용 (기본: false)
  maxImageFileSize?: number; // 이미지 최대 파일 크기(바이트). 미설정 시 10MB
  maxVideoFileSize?: number; // 동영상 최대 파일 크기(바이트). 미설정 시 100MB
}
```

</details>

---

## 사용 예제

### 읽기 전용 모드

```tsx
<LumirEditor
  editable={false}
  initialContent={savedContent}
  sideMenu={false}
  formattingToolbar={false}
/>
```

### 다크 테마

```tsx
<LumirEditor theme="dark" className="bg-gray-900 rounded-lg" />
```

### 콘텐츠 저장 및 불러오기

```tsx
import { useState, useEffect } from "react";
import { LumirEditor, ContentUtils } from "@lumir-company/editor";

function EditorWithSave() {
  const [content, setContent] = useState("");

  // 불러오기
  useEffect(() => {
    const saved = localStorage.getItem("content");
    if (saved && ContentUtils.isValidJSONString(saved)) {
      setContent(saved);
    }
  }, []);

  // 저장
  const handleChange = (blocks) => {
    const json = JSON.stringify(blocks);
    localStorage.setItem("content", json);
  };

  return (
    <LumirEditor initialContent={content} onContentChange={handleChange} />
  );
}
```

---

## 스타일링

### Tailwind CSS와 함께 사용

```tsx
import { LumirEditor, cn } from "@lumir-company/editor";

<LumirEditor
  className={cn(
    "min-h-[400px] rounded-xl",
    "border border-gray-200 shadow-lg",
    "focus-within:ring-2 focus-within:ring-blue-500",
  )}
/>;
```

### 커스텀 스타일

```css
/* globals.css */
.my-editor .bn-editor {
  padding: 20px 30px;
  font-size: 16px;
  line-height: 1.6;
}

.my-editor [data-content-type="heading"] {
  font-weight: 700;
  margin-top: 24px;
}
```

```tsx
<LumirEditor className="my-editor" />
```

---

## 트러블슈팅

### 필수 체크리스트

- [ ] CSS 임포트: `import "@lumir-company/editor/style.css"`
- [ ] 컨테이너 높이 설정: 부모 요소에 높이 지정 필수
- [ ] Next.js: `dynamic(..., { ssr: false })` 사용
- [ ] React 버전: 18.0.0 이상

### 자주 발생하는 문제

#### 1. 에디터가 보이지 않음

```tsx
// 잘못됨
<LumirEditor />;

// 올바름
import "@lumir-company/editor/style.css";
<div className="h-[400px]">
  <LumirEditor />
</div>;
```

#### 2. Next.js Hydration 오류

```tsx
// 잘못됨
import { LumirEditor } from "@lumir-company/editor";

// 올바름
const LumirEditor = dynamic(
  () =>
    import("@lumir-company/editor").then((m) => ({ default: m.LumirEditor })),
  { ssr: false },
);
```

#### 3. 이미지 업로드 실패

```tsx
// uploadFile 또는 s3Upload 중 하나는 반드시 설정!
<LumirEditor
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "development",
    path: "images",
  }}
/>
```

#### 4. 여러 이미지 업로드 시 중복 문제

```tsx
// 해결: appendUUID 사용
<LumirEditor
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "images",
    appendUUID: true, // 고유한 파일명 보장
  }}
/>
```

---

## 유틸리티 API

### ContentUtils

```tsx
import { ContentUtils } from "@lumir-company/editor";

// JSON 검증
ContentUtils.isValidJSONString('[{"type":"paragraph"}]'); // true

// JSON 파싱
const blocks = ContentUtils.parseJSONContent(jsonString);

// 기본 블록 생성
const emptyBlock = ContentUtils.createDefaultBlock();
```

### createS3Uploader

```tsx
import { createS3Uploader } from "@lumir-company/editor";

const uploader = createS3Uploader({
  apiEndpoint: "/api/s3/presigned",
  env: "production",
  path: "uploads",
  appendUUID: true,
});

// 직접 사용
const url = await uploader(imageFile);
```

## 관련 링크

- [npm Package](https://www.npmjs.com/package/@lumir-company/editor)
- [BlockNote Documentation](https://www.blocknotejs.org/)

---

## 변경 로그

### v0.4.10

- **Numbered List & Bullet List font size 14px**
  - Numbered List & Bullet List의 font size 14px로 일반 텍스트 크기와 통일성 있게 변경

### v0.4.10

- **동영상·이미지 업로드 진행률 표시**
  - S3 업로드 시 진행률이 0만 보이다가 100으로 바로 완료되던 문제 개선
  - `xhr.upload.onprogress`와 **보간 타이머**를 함께 사용해 중간 진행률(0→…→100)이 부드럽게 갱신되도록 변경
  - 업로드 시작 직후 `onProgress(0)` 호출, 완료 시 `onProgress(100)` 보장
  - README: `s3Upload.onProgress` 설명 및 업로드 진행률 표시 섹션 추가
  - README: `S3UploaderConfig`에 `onProgress`, `uploadTimeoutMs`, `maxRetries` 문서화

### v0.4.9

- **업로드 용량·타임아웃 사용자 설정**
  - `maxImageFileSize`, `maxVideoFileSize` prop 추가 (미설정 시 기본 10MB/100MB)
  - `isImageFile(file, maxSize?)`, `isVideoFile(file, maxSize?)` 시그니처 확장
  - README: 용량 및 타임아웃 설정 예시 및 `s3Upload.uploadTimeoutMs` 안내 보강

### v0.4.8

- README update (video & image upload)
- 버전 배포

### v0.4.6

- **README: S3 Presigned URL API**
  - Next.js App Router용 `app/api/s3/presigned/route.ts` 구현 예시 추가 (PutObjectCommand, getSignedUrl)
  - Next.js가 아닌 프로젝트: Express 예시 및 Remix/SvelteKit 등 동일 패턴 안내
- **README: 이미지·동영상 업로드 경로 분리**
  - `fileNameTransform`으로 이미지/동영상 prefix 분리 (`images/`, `videos/`) 예시 및 결과 경로 설명 추가

### v0.4.5

- **README: 이미지·동영상 업로드**
  - 지원 형식/용량, 삽입 경로, 설정 방법(s3Upload / uploadFile), 저장 데이터 구조, 삭제 콜백, 에러 처리 정리
  - 동영상 블록은 직접 재생 가능한 파일 URL만 지원한다는 안내 추가 (YouTube/Vimeo 링크 미지원)

### v0.4.4

- **Link Preview API 핸들러 내장**
  - `@lumir-company/editor/api/link-preview` 서브패스 export 추가
  - 표준 Web API (`Request`/`Response`) 기반으로 Next.js, Remix, SvelteKit 호환
  - 소비자는 1줄 re-export만으로 API 라우트 설정 가능 (220줄 route.ts 제거)
  - `linkPreviewHandler`, `fetchUrlMetadata`, `parseMetaTags` 함수 export
- **Placeholder 문서화**
  - Placeholder 사용 가이드 추가
- **README 개선**
  - 링크 프리뷰 서버사이드 요구사항 및 설정 방법 가이드 추가
  - 내장 API 핸들러 export 목록 문서화
  - Props API 테이블에 `placeholder`, `linkPreview` 추가

### v0.4.3

- **링크 프리뷰 기능 추가**
  - `linkPreview` prop으로 링크 미리보기 활성화 (카카오톡 스타일 OG 카드)
  - URL 붙여넣기 시 자동 링크 프리뷰 블록 생성 (빈 블록이면 교체, 텍스트 있으면 하단 삽입)
  - 슬래시 메뉴(`/`)에서 Link Preview 항목 추가
  - 드래그 리사이즈 지원 (좌우 너비, 하단 이미지 높이 조절)
  - 메타데이터에 이미지 없을 경우 이미지 영역 생략
  - 에러 카드 클릭 시 링크 이동 지원
  - `fetchLinkMetadata`, `clearMetadataCache`, `LinkMetadata` 타입 export
- **링크 툴바 커스텀**
  - 텍스트 링크를 링크 프리뷰 블록으로 전환하는 버튼 추가 (`replaceBlocks` 사용)
  - `linkPreview.apiEndpoint` 설정 시에만 전환 버튼 표시
- **placeholder prop 추가**
  - `placeholder` prop으로 에디터 빈 블록 안내 텍스트 설정
- **이미지 삭제 기능 추가**
  - `onImageDelete` 콜백 prop 추가 - 에디터에서 이미지 삭제 시 호출
  - S3 등 외부 스토리지에서 이미지 자동 삭제 지원
  - 지연 삭제 패턴으로 Undo/Redo 대응 가능
  - 이미지 URL 추출 및 삭제 감지 헬퍼 함수 내장
- **보안 강화**
  - URL 이스케이프 처리 추가 (XSS 방지)
  - LinkButton: `javascript:`, `data:`, `vbscript:`, `file:` 프로토콜 차단
  - 위험한 URL 입력 시 에러 메시지 표시
- **링크 삽입 버그 수정**
  - 플로팅 메뉴 링크 버튼: 텍스트 미선택 시에도 URL 텍스트로 링크 삽입 지원
  - `editor.focus()` 호출로 선택 상태 복원
- **README 개선**
  - 링크 프리뷰 사용 가이드 및 API 라우트 예시 추가
  - 이미지 삭제 섹션 추가 (지연 삭제 예시 포함)
  - S3 삭제 API 구현 예시 추가
  - Props API 문서 업데이트

### v0.4.2

- **코드 구조 리팩토링**
  - FloatingMenu 컴포넌트 분리 (Icons, 개별 버튼 컴포넌트)
  - 색상 상수 별도 파일로 분리 (`constants/colors.ts`)
  - 미사용 기능 제거 (FontSelect, FontSizeControl)
- **에러 처리 개선**
  - `LumirEditorError` 커스텀 에러 클래스 추가
  - `onError` 콜백 prop 추가 - 에러 발생 시 사용자 정의 핸들링 가능
  - 에러 발생 시 사용자 친화적 토스트 메시지 자동 표시
- **HTML 미리보기 개선**
  - sandbox 설정 명확화 (JavaScript 의도적 비활성화)
  - 드래그 리사이즈, 새 창 열기, 다운로드 기능 문서화
- **타입 개선**
  - `LumirErrorCode`, `LumirErrorDetails` 타입 export
  - `ColorItem` 타입 export

### v0.4.1

- `preserveExtension` prop 추가 - 확장자 자동 붙이기 제어 (기본: true)
- **중요**: 파일명 변환 시 확장자 위치 수정 (확장자가 항상 맨 뒤에 오도록)
- **Breaking Change**: `fileNameTransform` 파라미터 변경 - 이제 확장자 제외한 파일명만 전달됨
  - 이전: `fileNameTransform: (originalName, file) => ...` → originalName에 확장자 포함
  - 변경: `fileNameTransform: (nameWithoutExt, file) => ...` → nameWithoutExt에 확장자 제외
- 확장자 제거 사용 사례 문서화
- README 예제 및 설명 개선

### v0.4.0

- 파일명 변환 콜백 (`fileNameTransform`) 추가
- UUID 자동 추가 옵션 (`appendUUID`) 추가
- 여러 이미지 동시 업로드 시 중복 문제 해결
- 문서 대폭 개선

### v0.3.3

- 에디터 재생성 방지 최적화
- 타입 정의 개선