# 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 자동 추가 지원                                    |
| **로딩 스피너**         | 이미지 업로드 중 자동 스피너 표시                                                |
| **테이블**              | Notion 스타일 행·열·셀 grip 핸들, 셀 배경색, Excel 셀 붙여넣기 지원              |
| **글자 크기**           | 인라인 글자 크기 변경 (프리셋 8단계 + 기본), 구버전 호환 직렬화                  |
| **성능 최적화**         | 애니메이션 비활성화로 빠른 렌더링                                                |
| **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분) |
| **권한 검증**   | 프로덕션에서는 인증/인가 필수                 |
| **참조 카운트** | 같은 이미지를 여러 문서에서 사용하는지 확인   |
| **삭제 로그**   | 감사 추적을 위한 삭제 기록 저장 권장          |

---

## 테이블

슬래시 메뉴(`/`)에서 "Table"을 선택하거나 Excel/스프레드시트 셀을 붙여넣어 테이블을 만들 수 있습니다.

### Notion 스타일 grip 핸들

셀에 포커스하면 셀 주변에 핸들(grip)이 표시됩니다.

| 위치          | 동작                                                                  |
| ------------- | --------------------------------------------------------------------- |
| **상단 grip** | 클릭 → 열 메뉴 (열 삭제, 왼쪽/오른쪽에 열 추가, 색) / 드래그 → 열 이동 |
| **좌측 grip** | 클릭 → 행 메뉴 (행 삭제, 위/아래에 행 추가, 색) / 드래그 → 행 이동     |
| **우측 grip** | hover → 셀 메뉴 (셀 배경색 등)                                         |

- 행/열 메뉴가 열리면 해당 행/열 전체가 하이라이트됩니다
- 표 우측/하단 가장자리 hover 시 행/열 추가 버튼이 표시됩니다

### 셀 배경색·정렬

- **단일 셀**: 우측 grip 또는 행/열 메뉴의 "색" 항목에서 셀 배경색 적용
- **다중 셀**: 셀을 드래그로 범위 선택한 뒤 플로팅 툴바의 색상 버튼으로 일괄 적용
- **정렬**: 셀 선택 후 툴바의 정렬 버튼으로 셀 단위 텍스트 정렬 적용

### Excel/스프레드시트 붙여넣기

Excel 등에서 복사한 셀 범위를 붙여넣으면(`Ctrl+V`) 이미지가 아닌 **편집 가능한 테이블**로 삽입됩니다. 셀 배경색, 글자색, 정렬, 굵게/기울임/밑줄 서식이 함께 변환됩니다.

### 테이블 기능 설정 (`tables` prop)

```tsx
<LumirEditor
  tables={{
    splitCells: true, // 셀 병합/분할 (기본: true)
    cellBackgroundColor: true, // 셀 배경색 (기본: true)
    cellTextColor: true, // 셀 글자색 (기본: true)
    headers: true, // 헤더 행/열 (기본: true)
  }}
/>
```

테이블 핸들 UI 전체를 끄려면 `tableHandles={false}`를 사용합니다.

---

## 글자 크기

텍스트를 선택한 뒤 **포매팅 툴바** 또는 **상단 고정 툴바(FloatingMenu)** 의 글자 크기 드롭다운으로 인라인 글자 크기를 변경할 수 있습니다.

- 프리셋: **기본**(14px, 스타일 제거) / 10 / 12 / 14 / 16 / 18 / 20 / 24 / 28 (px)
- 테이블 셀 내부 텍스트에도 동일하게 적용됩니다 (인라인 스타일)
- 외부 HTML(웹페이지·Excel 등)을 붙여넣을 때의 글자 크기는 가져오지 않습니다

### 하위호환 직렬화 포맷 (중요)

글자 크기는 저장 JSON에서 `styles` 맵이 아닌 **styled-text의 형제(sibling) 키 `fontSize`** 로 직렬화됩니다:

```json
{
  "type": "paragraph",
  "content": [
    { "type": "text", "text": "큰 글씨", "styles": { "bold": true }, "fontSize": "18px" }
  ]
}
```

이유: BlockNote는 `styles` 맵에 스키마에 없는 스타일 키가 있으면 예외를 던지므로,
`styles.fontSize`로 저장하면 **fontSize 스펙이 없는 구버전 SDK(≤0.4.15)가 해당 JSON을
`initialContent`로 로드할 때 에디터가 크래시**합니다. 형제 키 방식은 구버전에서
조용히 무시되어(글자 크기만 미표시) 안전하게 로드됩니다.

- 에디터 로드/저장 시 변환은 자동입니다 (`initialContent` ↔ `onContentChange`)
- BlockNote 외부 렌더러에서 저장 JSON을 직접 렌더링한다면, 공개 export된
  `liftFontSize(blocks)`로 형제 키를 `styles.fontSize`로 복원한 뒤 사용하세요
- 직렬화 형태 타입은 `SerializedStyledText`로 export됩니다

> ⚠️ 에디터 블록 JSON을 외부로 내보내는 새 경로를 추가할 경우 반드시
> `lowerFontSize`를 거쳐야 합니다. `styles.fontSize`가 저장 JSON에 유출되면
> 구버전 소비 앱이 크래시합니다.

---

## HTML 미리보기

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

### 사용 방법

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

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

```tsx
<LumirEditor />
```

- **지원 파일 형식**: `.html`, `.htm`
- **특징**:
  - 편집 불가능한 순수 미리보기
  - 접기/펼치기 기능
  - 안전한 sandbox 처리 (`allow-scripts`·`allow-same-origin` 미허용 → JavaScript 실행 및 부모 페이지 접근 차단)
  - 파일명 표시

#### 2. 슬래시 메뉴

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

```
/ → HTML Preview
```

### 특징

- **iframe 기반**: HTML 문서를 독립된 iframe에서 안전하게 렌더링
- **Sandbox 보안**: `sandbox="allow-popups allow-forms"` — `allow-scripts`와 `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 { HtmlPreviewBlock } 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-popups allow-forms"` 속성으로 보안이 강화되어 있습니다 (`allow-scripts`·`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" \| ThemeObject`  | `"light"`   | 테마 (커스텀 테마 객체 지원)              |
| `allowVideoUpload` | `boolean`                           | `false`     | 동영상 업로드 허용                        |
| `tables`           | `TableConfig`                       | 모두 `true` | 테이블 기능 설정 ([테이블](#테이블) 참고) |
| `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?: {
    splitCells?: boolean; // 셀 병합/분할 (기본: true)
    cellBackgroundColor?: boolean; // 셀 배경색 (기본: true)
    cellTextColor?: boolean; // 셀 글자색 (기본: true)
    headers?: boolean; // 헤더 행/열 (기본: true)
  };
  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: ThemeObject; dark: ThemeObject }; // 에디터 테마 (기본: "light")
  formattingToolbar?: boolean; // 서식 툴바 표시 (기본: true)
  linkToolbar?: boolean; // 링크 툴바 표시 (기본: true)
  sideMenu?: boolean; // 사이드 메뉴 표시 (기본: true)
  sideMenuAddButton?: boolean; // 사이드 메뉴 + 버튼 표시 (기본: false, 드래그 핸들만 표시)
  emojiPicker?: boolean; // 이모지 선택기 표시 (기본: true)
  filePanel?: boolean; // 파일 패널 표시 (기본: true)
  tableHandles?: boolean; // 테이블 핸들 표시 (기본: true)
  floatingMenu?: boolean; // 상단 고정 플로팅 메뉴 표시 (기본: false)
  floatingMenuPosition?: "sticky" | "fixed"; // 플로팅 메뉴 위치 (기본: "sticky")
  columnDivider?: boolean; // 2단(다단) 컬럼 사이 중앙 세로 구분선 표시 (기본: false)
  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.23 (2026-06-22)

- **다중 블록(여러 문단/리스트) 선택 시 글자 크기 스테퍼 누적 안 되던 버그 수정**: BlockNote `getActiveStyles()`는 선택 끝(`$to`)의 마크만 읽어 다중 블록 선택에서 빈 값을 돌려줬고, 그 결과 스테퍼 표기가 14에 고정돼 +/− 연속 클릭이 매번 같은 값을 재적용했음. 선택 범위 전체를 스캔하는 `readSelectionFontSize` + 낙관적 갱신으로 해결(연속 증감 누적, 표기 정상 갱신)
- **툴바 드롭다운 조작 시 선택 하이라이트가 사라지던 문제 보완**: 텍스트를 범위 선택한 뒤 글자 크기 등 툴바 드롭다운을 열면 에디터가 blur되어 브라우저 네이티브 선택 하이라이트가 사라졌음(적용 자체는 정상). blur 상태에서도 선택 범위에 인라인 데코레이션을 입히는 `InactiveSelectionExtension` 추가로 선택 영역이 계속 보이도록 함(색상 CSS 변수 `--lumir-inactive-selection`)

### v0.4.22 (2026-06-22)

- **글자 크기 1px 단위 조절** *(신규)*
  - 글자 크기 드롭다운(포매팅 툴바·플로팅 메뉴) 상단에 **−/+ 스테퍼 + 직접 입력** 추가 → 프리셋 사이 값(13·15·17·21px…)을 1px 단위로 지정 (`↑`/`↓` 키로도 증감)
  - 허용 범위 **8~96px**(범위 밖 입력은 자동 보정), 명시 크기가 없으면 14px 기준으로 증감. 스테퍼 클릭 시 드롭다운은 닫히지 않아 연속 조절 가능
  - 기존 프리셋(10·12·14·16·18·20·24·28px)·"기본" 리셋은 그대로 유지. 저장 JSON·하위호환(형제 키 직렬화)은 변경 없음
  - 공개 API에 `FONT_SIZE_MIN`/`FONT_SIZE_MAX`/`FONT_SIZE_DEFAULT_PX`/`FONT_SIZE_STEP` 및 `parseFontSizePx`/`clampFontSizePx`/`toFontSizeValue` 추가
  - **글자 크기 스타일을 동기 스펙(`createStyleSpec`)으로 변경**: 기존 React 스펙은 span 내용이 비동기 렌더되어, 크기 적용 직후 포매팅 툴바가 좌상단으로 튀는 문제가 있었음(선택 영역 DOM 좌표가 순간 (0,0)으로 측정됨). 동기 렌더로 바꿔 툴바가 선택 위치에 고정됨. HTML 직렬화(`data-style-type`/`data-value`)는 동일

### v0.4.21 (2026-06-18)

- **Word/docx 표 붙여넣기 품질 개선** *(신규)*
  - **에디터 너비 자동 맞춤**: 에디터보다 넓은 표를 붙여넣으면 각 열 비율을 유지한 채 에디터 너비에 맞춰 축소(가로 스크롤 방지). 열 너비를 colgroup/셀(pt/px/%)에서 읽어 `columnWidths`로 적용
  - **셀 서식 보존 확대**: 기존(굵게·기울임·밑줄·취소선·글자색·배경색·텍스트 정렬)에 더해 **글자 크기**와 **세로 정렬**(middle/bottom)도 보존
  - 한계: 글자 크기는 셀 단위 균일 적용, 글꼴(font-family)·정확한 hex 색은 BlockNote 미지원(색은 10색 팔레트 근사)

### v0.4.20 (2026-06-18)

- **2단 컬럼 구분선 — 생성 시 선택** *(신규)*
  - 슬래시 메뉴에서 `2단 컬럼` / `2단 컬럼 (구분선)` 중 골라 삽입 → 선택한 구분선 유무가 그 블록에 **고정**(문서에 `showDivider`로 저장·라운드트립). 구분선 있는/없는 컬럼을 섞어 사용 가능
  - v0.4.19의 전역 `columnDivider` prop과 별개로, **블록별**로 구분선을 지정
- **표 셀에서 Ctrl/Cmd+A → 표 전체 선택** *(신규)*
  - 셀에 포커스(커서/선택) 시 Ctrl/Cmd+A로 표의 모든 셀을 한 번에 선택(일괄 색·정렬·삭제 등에 유용)
  - 이미 표 전체가 선택된 상태에서 다시 누르면 문서 전체로 단계적 확장. 표 밖에서는 기본 동작 유지

### v0.4.19 (2026-06-17)

- **2단 컬럼 중앙 세로 구분선 (`columnDivider` 옵션)** *(신규)*
  - `columnDivider` prop(기본 `false`)으로 2단(다단) 컬럼 사이 중앙에 세로 구분선 표시
  - 구분선 양쪽에 드래그 핸들(grip) 너비만큼 여백을 둬 핸들과 겹치지 않음
  - 색·여백을 CSS 변수로 조절: `--lumir-column-divider-color`(기본 `#e5e7eb`), `--lumir-column-grip-space`(기본 28px)
- **표 전체 종횡비 고정 스케일 (코너 드래그)** *(신규)*
  - 표 우하단 모서리 hover 시 대각 리사이즈 커서 → 드래그로 표 전체를 종횡비 고정 균일 배율로 확대/축소
  - 모든 열 너비(`colwidth`)·행 높이(`rowHeight`)에 동일 배율 적용 → 행·열 상대 비율과 표 종횡비 유지
  - 셀 focus 없이 표 hover만으로 동작(코어 hover 상태 기반), 기존 행/열 개별 리사이즈와 충돌 없음
  - `tableHandles` prop으로 게이트(행/열 리사이즈와 동일)

### v0.4.18 (2026-06-17)

- **표 열 삭제 버그 수정 (병합 셀 포함)**
  - 그립 메뉴로 첫 열/행 삭제 시, 그립 클릭으로 ProseMirror 선택이 표 밖으로 빠져 삭제가 무시되던 문제 수정(삭제 대상 표를 포커스 표 기준으로 결정적으로 탐색)
  - **세로 병합(rowspan) 인접 열**이 있을 때 첫 열이 삭제되지 않던 문제 수정 — 코어 prosemirror-tables가 행의 유일 셀을 지울 때 빈 셀을 남겨(`tableRow+` 스키마) `fixTables`가 원복하던 케이스. 열 삭제를 표 재구성 방식으로 변경해 colspan/rowspan 병합을 안전하게 처리
  - 병합 셀이 행 축소로 collapse될 때 **행 높이를 보존**(원래 차지하던 행 높이 합을 유지)

### v0.4.17 (2026-06-16)

- **표 행 높이(세로) 리사이즈**
  - 행 경계 hover → 드래그로 행 높이 조절(가로 열 리사이즈와 대칭). 드래그 중 셀 높이가 마우스를 따라 실시간 반영
  - 높이는 셀 `rowHeight` attr로 저장·라운드트립. 행/열 추가·구조 편집 시에도 보존
- **표 블록 정렬(좌/가운데/우)**
  - 상단 포매팅 툴바 + 블록 드래그핸들 메뉴에서 표 전체를 에디터 영역 기준 좌/가운데/우 정렬
- **표 하단 여백 축소**: 표 아래 불필요한 예약 공백(약 16px) 제거(핸들 여백은 유지)
- **2단 컬럼(다단) 레이아웃** *(신규)*
  - 슬래시 메뉴 `/2단 컬럼`으로 좌우 2단 삽입, 각 단에 일반 블록 자유 배치·편집
  - **블록 DnD**: 블록을 다른 블록의 좌/우 가장자리로 끌어다 놓으면 2단 컬럼 생성(노션식, 세로 드롭 인디케이터)
  - 빈 컬럼/1단 columnList 자동 정리 등 문서 불변식 보호
  - 공식 `@blocknote/xl-multi-column`(AGPL) 대신 MIT 안전 자체 구현
  - *제한(후속 예정)*: 컬럼 안↔밖 DnD/3단 추가, 컬럼 너비 리사이즈, 다중 블록 드래그

### v0.4.16 (2026-06-05)

- **인라인 글자 크기 (Font Size)**
  - 포매팅 툴바·상단 고정 툴바(FloatingMenu)에 글자 크기 드롭다운 추가 (기본 + 10~28px 프리셋 8단계)
  - 커스텀 `fontSize` 스타일 스펙 등록 (`FontSize` export)
  - **구버전 호환 직렬화**: 저장 JSON에는 `styles.fontSize` 대신 styled-text 형제 키 `fontSize`로 기록 — fontSize 스펙이 없는 구버전 SDK(≤0.4.15)에서도 파싱 오류 없이 로드(글자 크기만 무시)
  - `liftFontSize`/`lowerFontSize` 변환 유틸 및 `SerializedStyledText` 타입 공개 export
- **`floatingMenu` 사용 시 팝업 포매팅 툴바 동작 개선**
  - 일반 텍스트 선택 시 선택 팝업 툴바를 표시하지 않음 (상단 고정 툴바와 중복 + 상단 툴바에서 스타일 적용 직후 팝업이 잘못된 위치(0,0)에 재표시되던 문제 수정)
  - 테이블 셀 컨텍스트(셀 병합·세로 정렬·셀 배경)와 이미지/노드 선택(캡션·교체·다운로드 등)은 팝업에만 있는 도구이므로 기존대로 팝업 표시

### v0.4.15 (2026-06-05)

- **Notion 스타일 테이블 셀 색상·정렬·포커스 핸들**
  - 셀 focus 시 상(열)·좌(행)·우(셀) gutter/grip 표시, grip 클릭으로 행·열·셀 드롭다운 메뉴 (`LumirTableHandlesController`)
  - 셀 배경색 지원: 셀 메뉴의 "색" 항목 및 플로팅 툴바에서 다중 셀 드래그 선택 후 일괄 적용
  - 텍스트 색·배경 vs 셀 배경을 구분한 컨텍스트 라벨 색상 컨트롤
  - 상단 고정 툴바(FloatingMenu)의 정렬·배경색 버튼이 테이블 셀에 올바르게 적용되도록 수정
  - 행/열 grip 메뉴가 열려 범위 하이라이트 중일 때 셀 우측 grip 미노출 처리

### v0.4.14 (2026-05-29)

- **Excel/스프레드시트 셀 붙여넣기 → 편집 가능한 테이블**
  - Excel 복사 시 클립보드의 비트맵 이미지가 업로드되어 테이블이 무시되던 문제 수정
  - 클립보드 HTML에 `<table>`이 있으면 이미지보다 우선 파싱하여 실제 테이블 블록 생성
  - Excel 서식(셀 배경, 글자색, 정렬, 굵게/기울임/밑줄)을 BlockNote 속성으로 매핑

### v0.4.13 (2026-04-03)

- **@tiptap/core 외부화(externalize)**
  - 번들에 포함된 @tiptap/core 중복으로 발생하던 `proseMirrorPlugins` 런타임 에러 수정
  - `@tiptap/core`를 peerDependency로 전환하고 빌드에서 external 처리

### v0.4.12 (2026-04-03)

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

### v0.4.10 (2026-03-18)

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

### v0.4.9 (2026-03-17)

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

### v0.4.8 (2026-03)

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

### v0.4.6 (2026-03)

- **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 (2026-03-06)

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

### v0.4.4 (2026-03-05)

- **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 (2026-02-23)

- **링크 프리뷰 기능 추가**
  - `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 (2026-02-23)

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

### v0.4.1 (2026-01-15)

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

### v0.4.0 (2026-01-15)

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

### v0.3.3 (2025-12-11)

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

## 📦 배포 (자동)

이 패키지(`@lumir-company/editor`)는 **release-please** + self-hosted GitHub Actions 러너로 자동 배포됩니다. 버전을 수동으로 올리거나 `npm publish`를 직접 실행할 필요가 없습니다.

### 버전 규칙 (Semantic Versioning)

커밋 메시지([Conventional Commits](https://www.conventionalcommits.org/))가 다음 버전을 자동 결정합니다:

| 커밋 prefix | 버전 증가 | 예시 |
| --- | --- | --- |
| `fix: …` | **patch** | 1.2.3 → 1.2.4 |
| `feat: …` | **minor** | 1.2.3 → 1.3.0 |
| `feat!: …` 또는 본문 `BREAKING CHANGE:` | **major** | 1.2.3 → 2.0.0 |
| `chore:` `docs:` `refactor:` `test:` 등 | 릴리스 없음 | — |

### 배포 흐름

1. `master` 브랜치에 conventional commit을 push 한다.
2. **release-please**가 `chore(master): release X.Y.Z` 형태의 **Release PR**을 자동 생성한다 (버전 bump + `CHANGELOG.md` 갱신).
3. 그 PR을 검토 후 **머지**한다. ← 사람이 하는 유일한 단계
4. 머지되면 자동으로 **Git 태그 + GitHub Release 생성 + npm publish** 가 실행된다 (`.github/workflows/release.yml`, self-hosted 러너 `lumir-ci`).

> npm 인증은 repo secret `NPM_TOKEN`(Automation/Publish 토큰), release-please는 `RELEASE_PLEASE_TOKEN`(PAT)을 사용한다.

