# @lumir-company/editor

**의존성 0(vanilla HTML/JS/CSS)** 리치 텍스트 에디터 — BlockNote-JSON 라운드트립 호환.

[![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)

> 구 BlockNote 기반 `@lumir-company/editor@0.4.x`의 **드롭인 대체**입니다. 동일한 API 표면(`LumirEditor` 컴포넌트·props·타입·`s3Upload` 계약)을 유지하되, 코어에서 **React / BlockNote / ProseMirror 런타임을 완전히 제거**했습니다. 코어는 react-free라 서버·명령형 환경에서도 사용할 수 있고, React 컴포넌트는 선택적 래퍼로 제공됩니다.

---

## 목차

- [특징](#특징)
- [설치](#설치)
- [빠른 시작](#빠른-시작)
  - [React 드롭인](#1-react-드롭인)
  - [Next.js](#2-nextjs)
  - [react-free 코어 (vanilla)](#3-react-free-코어-vanilla)
- [Exports (서브패스)](#exports-서브패스)
- [이미지 업로드](#이미지-업로드)
- [동영상·오디오·파일 업로드](#동영상오디오파일-업로드)
- [업로드 진행률](#업로드-진행률)
- [이미지·비디오 삭제](#이미지비디오-삭제)
- [테이블](#테이블)
- [2단(다단) 컬럼](#2단다단-컬럼)
- [글자 크기](#글자-크기)
- [링크](#링크)
- [Placeholder](#placeholder)
- [테마](#테마)
- [Props API](#props-api)
- [react-free 코어 API](#react-free-코어-api)
- [유틸리티 API](#유틸리티-api)
- [스타일링](#스타일링)
- [트러블슈팅](#트러블슈팅)
- [0.4.x → 0.5.0 마이그레이션](#04x--050-마이그레이션)
- [변경 이력](#변경-이력)
- [라이선스](#라이선스)

---

## 특징

| 특징 | 설명 |
| --- | --- |
| **의존성 0** | 코어에 React/BlockNote/ProseMirror 런타임 없음(vanilla HTML/JS/CSS). React는 선택적 peer |
| **드롭인 호환** | 구 `@lumir-company/editor@0.4.x`와 동일한 컴포넌트·props·타입·저장 JSON |
| **react-free 코어** | `mountLumirEditor()` 명령형 API로 서버 컴포넌트·비-React 환경에서도 사용 가능 |
| **이미지 업로드** | 드래그앤드롭·붙여넣기·슬래시 메뉴, S3 presigned URL 내장, 파일명 커스터마이징, 로딩 표시 |
| **동영상/오디오/파일** | `allowVideoUpload`·`allowAudioUpload`·`allowFileUpload` opt-in |
| **테이블** | Notion 스타일 grip 핸들, 셀 배경/글자색·정렬, 병합/분할, 행 높이·열 너비 리사이즈, 표 전체 종횡비 스케일, 에디터 폭 자동 맞춤, Excel/Word 붙여넣기 |
| **2단 컬럼** | 노션식 좌우 컬럼 레이아웃(MIT 자체 구현), 블록 DnD로 생성, 블록별/전역 구분선 |
| **글자 크기** | 인라인 글자 크기(프리셋 + 1px 스테퍼, 8~96px), 구버전 안전 직렬화 |
| **여러 줄 선택·편집** | 블록 경계를 넘는 드래그로 여러 줄 선택 → 서식 일괄 적용, 선택 영역 삭제·타이핑·붙여넣기·부분 복사(무손실), Shift+클릭·`Ctrl/Cmd+A` 전체선택, 단일 Undo |
| **링크** | URL 붙여넣기 → 인라인 링크, Notion식 링크 툴바(hover 툴팁 → 편집 popup) |
| **직렬화** | 블록 JSON ↔ HTML ↔ Markdown 상호 변환 유틸 공개 export |
| **TypeScript** | 전 표면 `.d.ts` 제공 |
| **테마** | 라이트/다크 + 커스텀 테마 객체 |

### 지원 파일 형식

| 종류 | 형식 | 기본 용량 한도 |
| --- | --- | --- |
| 이미지 | PNG · JPEG/JPG · GIF · WebP · BMP (SVG는 XSS 방지로 제외) | 10MB |
| 동영상 | MP4 · WebM · OGG · MOV | 100MB |
| 오디오/파일 | `allowAudioUpload`·`allowFileUpload` 활성화 시 | `maxAudioFileSize` 등으로 설정 |

---

## 설치

```bash
npm i @lumir-company/editor
# 또는
yarn add @lumir-company/editor
# 또는
pnpm add @lumir-company/editor
```

**Peer dependencies (선택):**

- `react` ≥ 18.0.0
- `react-dom` ≥ 18.0.0

> React/`react-dom`은 **React 컴포넌트(`LumirEditor`)나 드롭인 루트를 사용할 때만** 필요합니다. `./core` 서브패스(vanilla)만 사용한다면 React 없이 동작합니다.

---

## 빠른 시작

> **중요**: `@lumir-company/editor/style.css`를 반드시 임포트하세요. 임포트하지 않으면 에디터가 정상적으로 렌더링되지 않습니다.

### 1. React 드롭인

```tsx
import LumirEditor from "@lumir-company/editor";        // default export
// 또는: import { LumirEditor } from "@lumir-company/editor";
import "@lumir-company/editor/style.css";

export default function App() {
  return (
    <div style={{ height: 500 }}>
      <LumirEditor
        initialContent={blocks}
        editable
        s3Upload={{ apiEndpoint: "/api/s3/presigned", env: "production", path: "cms/wiki", appendUUID: true }}
        onContentChange={(blocks) => save(JSON.stringify(blocks))}
        onImageDelete={(url) => deleteFromS3(url)}
      />
    </div>
  );
}
```

> 컨테이너에 **높이**를 지정해야 에디터가 보입니다.

### 2. Next.js

브라우저 전용 API를 사용하므로 SSR을 비활성화합니다.

```tsx
"use client";

import dynamic from "next/dynamic";
import "@lumir-company/editor/style.css";

const LumirEditor = dynamic(
  () => import("@lumir-company/editor").then((m) => ({ default: m.LumirEditor })),
  { ssr: false },
);

export default function EditorPage() {
  return (
    <div style={{ height: 500 }}>
      <LumirEditor />
    </div>
  );
}
```

### 3. react-free 코어 (vanilla)

React 없이 임의의 DOM에 마운트하는 명령형 API입니다. 서버 컴포넌트·순수 JS·다른 프레임워크에서 사용할 수 있습니다.

```js
import { mountLumirEditor } from "@lumir-company/editor/core";
import "@lumir-company/editor/style.css";

const editor = mountLumirEditor(document.getElementById("host"), {
  initialContent: blocks,
  s3Upload: { apiEndpoint: "/api/s3/presigned", env: "production", path: "docs" },
  onContentChange: (blocks) => save(blocks),
});

editor.getDocument();   // 현재 블록 JSON
editor.getHTML();       // HTML 문자열
editor.getMarkdown();   // Markdown 문자열
editor.destroy();       // 정리
```

자세한 인스턴스 메서드는 [react-free 코어 API](#react-free-코어-api)를 참고하세요.

---

## Exports (서브패스)

| 서브패스 | 내용 | React 필요 |
| --- | --- | --- |
| `.` | React `LumirEditor`(default + named) + 코어/유틸 전체 (`"use client"`) | ✅ |
| `./react` | `.`과 동일 표면(하위호환 별칭) | ✅ |
| `./core` | react-free 코어(`mountLumirEditor`·직렬화·유틸) — 서버 컴포넌트 안전 | ❌ |
| `./style.css` | 단일 번들 CSS | — |

각 서브패스는 `types`(.d.ts) / `import`(ESM) / `require`(CJS) 조건을 모두 제공합니다.

> **참고**: 구버전에 있던 `@lumir-company/editor/api/link-preview` 서브패스는 **제거**되었습니다. [마이그레이션](#04x--050-마이그레이션)을 참고하세요.

---

## 이미지 업로드

이미지는 **붙여넣기 · 드래그 앤 드롭 · 슬래시 메뉴(`/` → Image) · 사이드/플로팅 메뉴**로 삽입됩니다. 업로드 방식은 아래 우선순위로 결정됩니다.

1. `uploadFile` prop이 있으면 → 해당 함수로 업로드
2. 없고 `s3Upload`가 있으면 → S3 presigned URL 업로드
3. 둘 다 없으면 → 파일 삽입 시 업로드 실패

### S3 업로드 (권장)

```tsx
<LumirEditor
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "blog/images",
  }}
/>
```

**S3 저장 경로**: `{env}/{path}/{filename}` — 예: `production/blog/images/my-photo.png`

#### API 엔드포인트 계약

클라이언트는 `GET {apiEndpoint}?key={파일키}&contentType={MIME}` 형태로 요청하고, 서버는 다음 JSON을 반환해야 합니다.

```json
{
  "presignedUrl": "https://s3.amazonaws.com/bucket/upload-url",
  "publicUrl": "https://cdn.example.com/production/blog/images/my-photo.png"
}
```

#### Presigned URL API 예시 (Next.js App Router)

`app/api/s3/presigned/route.ts`:

```ts
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!,
  },
  // ⚠️ 필수: AWS SDK v3(≥3.729)는 기본 체크섬(WHEN_SUPPORTED)으로 presigned PUT 서명에
  // x-amz-checksum-crc32 를 넣는데, 브라우저 PUT은 Content-Type만 전송 → 불일치로 S3 403.
  // 아래처럼 체크섬을 "요구될 때만" 계산하도록 낮춰야 브라우저 직접 업로드가 통과됩니다.
  requestChecksumCalculation: "WHEN_REQUIRED",
  responseChecksumValidation: "WHEN_REQUIRED",
});

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const key = searchParams.get("key");                 // 업로드할 파일 키 ({env}/{path}/{filename})
  const contentType = searchParams.get("contentType"); // MIME (선택, 없으면 application/octet-stream)
  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 }); // 유효 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`.

> **주의**: `requestChecksumCalculation` / `responseChecksumValidation`를 `"WHEN_REQUIRED"`로 두지 않으면 최신 AWS SDK v3에서 브라우저 업로드가 **403**으로 실패합니다. 이 레포의 동작 예시는 `lumir-editor-test/src/app/api/s3/presigned/route.ts`에 있습니다.

> Express / Remix / SvelteKit 등도 동일하게 `key`·`contentType`을 받아 `{ presignedUrl, publicUrl }`을 반환하는 GET 라우트를 만들면 됩니다. `apiEndpoint`만 해당 서버 주소로 맞추세요.

### 파일명 커스터마이징

여러 파일을 동시에 올릴 때 이름 충돌을 방지합니다. 기본적으로 확장자는 자동으로 붙습니다(`preserveExtension: false`로 끌 수 있음).

```tsx
<LumirEditor
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "uploads",
    // 확장자를 제거한 이름(nameWithoutExt)이 전달됩니다
    fileNameTransform: (nameWithoutExt, file) => `user123_${nameWithoutExt}`,
    appendUUID: true,          // 변환 후 UUID를 확장자 앞에 추가
    // preserveExtension: false // 확장자를 붙이지 않음(서버에서 WebP 변환 등)
  }}
/>
```

**결과**: `photo.png` → `user123_photo_550e8400-…-446655440000.png`

### 커스텀 업로더

```tsx
<LumirEditor
  uploadFile={async (file) => {
    const fd = new FormData();
    fd.append("file", file);
    const res = await fetch("/api/upload", { method: "POST", body: fd });
    const { url } = await res.json();
    return url; // 공개 URL 문자열 반환
  }}
/>
```

### 헬퍼: `createS3Uploader`

```tsx
import { createS3Uploader } from "@lumir-company/editor";

const s3Uploader = createS3Uploader({
  apiEndpoint: "/api/s3/presigned",
  env: "production",
  path: "images",
  appendUUID: true,
});

<LumirEditor uploadFile={s3Uploader} />;
// 또는 독립 사용: const url = await s3Uploader(file);
```

---

## 동영상·오디오·파일 업로드

동영상은 `allowVideoUpload={true}`일 때만 활성화됩니다. 업로드 설정(`s3Upload`/`uploadFile`)은 이미지와 **동일하게 공유**됩니다.

```tsx
<LumirEditor
  allowVideoUpload
  maxVideoFileSize={200 * 1024 * 1024} // 200MB (기본 100MB)
  s3Upload={{ apiEndpoint: "/api/s3/presigned", env: "production", path: "videos", appendUUID: true }}
/>
```

- **삽입 경로**: 붙여넣기, 드래그 앤 드롭, 슬래시 메뉴("Video"), 플로팅/사이드 메뉴
- **지원 URL**: 비디오 블록의 `url`은 **직접 재생 가능한 파일 URL**(예: `.mp4`, `.webm`, `.ogg`)만 지원합니다. YouTube/Vimeo 등 스트리밍 페이지 URL은 `<video>`로 재생되지 않습니다.
- **오디오/일반 파일**: `allowAudioUpload` / `allowFileUpload`로 opt-in. 용량은 `maxAudioFileSize` 등으로 조절합니다.

### 이미지·동영상 경로 분리

`fileNameTransform`의 두 번째 인자 `file`로 종류를 구분해 prefix를 나눌 수 있습니다.

```tsx
fileNameTransform: (nameWithoutExt, file) => {
  const isVideo = file.type.startsWith("video/");
  return `${isVideo ? "videos" : "images"}/${nameWithoutExt}`;
}
// 결과: production/uploads/videos/clip_xxx.mp4, production/uploads/images/photo_xxx.png
```

### 저장 데이터 구조

```json
{ "type": "image", "props": { "url": "https://cdn/…/photo.png", "caption": "", "previewWidth": 512 }, "content": [], "children": [] }
{ "type": "video", "props": { "url": "https://cdn/…/clip.mp4" }, "content": [], "children": [] }
```

---

## 업로드 진행률

S3 업로드 시 `s3Upload.onProgress` 콜백으로 0~100% 진행률을 받을 수 있습니다. 에디터는 이 값을 툴바에 `n%`로 자동 표시합니다. 대용량 파일에서 브라우저가 `progress` 이벤트를 드물게 보내는 경우를 대비해 **중간 진행률을 보간**하여 부드럽게 갱신합니다.

```tsx
<LumirEditor
  allowVideoUpload
  s3Upload={{
    apiEndpoint: "/api/s3/presigned",
    env: "production",
    path: "videos",
    uploadTimeoutMs: 180000, // PUT 타임아웃(ms). 기본 120000
    maxRetries: 2,           // PUT 실패 재시도. 기본 2(최대 3회 시도)
    onProgress: (percent) => console.log(`${percent}%`),
  }}
/>
```

- `onProgress`는 **S3 PUT 요청 시에만** 호출됩니다(presigned URL 요청 단계 제외).
- 업로드 시작 직후 `onProgress(0)`, 완료 시 `onProgress(100)`이 보장됩니다.

---

## 이미지·비디오 삭제

에디터에서 이미지 또는 비디오 블록이 삭제되면 `onImageDelete(url)`이 호출됩니다(둘 다 동일 콜백).

```tsx
<LumirEditor
  s3Upload={{ /* … */ }}
  onImageDelete={(url) => {
    // S3 등 외부 스토리지 삭제
  }}
/>
```

### 권장: 지연 삭제 (Undo/Redo 대응)

Undo로 복원 가능하도록 실제 삭제를 지연시키는 패턴을 권장합니다.

```tsx
"use client";
import { useRef, useCallback } from "react";

function Editor() {
  const pending = useRef(new Map<string, ReturnType<typeof setTimeout>>());

  const handleImageDelete = useCallback((url: string) => {
    if (pending.current.has(url)) return;
    const t = setTimeout(async () => {
      pending.current.delete(url);
      await fetch(`/api/s3/delete?url=${encodeURIComponent(url)}`, { method: "DELETE" });
    }, 5 * 60 * 1000); // 5분 후 삭제
    pending.current.set(url, t);
  }, []);

  return <LumirEditor s3Upload={{ /* … */ }} onImageDelete={handleImageDelete} />;
}
```

| 항목 | 권장 |
| --- | --- |
| Undo/Redo | 지연 삭제(5~10분)로 복원 가능하게 구현 |
| 권한 검증 | 프로덕션에서는 인증/인가 필수 |
| 참조 카운트 | 같은 URL을 여러 문서에서 쓰는지 확인 |

---

## 테이블

슬래시 메뉴(`/` → Table)나 Excel/Word 셀 붙여넣기로 표를 만듭니다.

### Notion 스타일 grip 핸들

셀에 포커스하면 주변에 grip 핸들이 표시됩니다.

| 위치 | 동작 |
| --- | --- |
| **상단 grip** | 클릭 → 열 메뉴(삭제 / 좌·우 열 추가 / 색) · 드래그 → 열 이동 |
| **좌측 grip** | 클릭 → 행 메뉴(삭제 / 위·아래 행 추가 / 색) · 드래그 → 행 이동 |
| **우측 grip** | hover → 셀 메뉴(셀 배경색 등) |

- 행/열 메뉴가 열리면 해당 행/열 전체가 하이라이트됩니다.
- 표 우측/하단 가장자리 hover 시 행/열 추가 버튼이 나타납니다.

### 셀 색·정렬

- **단일 셀**: 우측 grip 또는 행/열 메뉴의 "색"에서 배경색/글자색 적용
- **다중 셀**: 드래그로 범위 선택 후 플로팅 툴바 색상/정렬 버튼으로 일괄 적용
- **표 전체 선택**: 셀 포커스 상태에서 `Ctrl/Cmd + A` → 표의 모든 셀 선택(다시 누르면 문서 전체로 확장)

### 리사이즈 · 스케일 · 정렬

- **열 너비**: 열 경계 드래그
- **행 높이**: 행 경계 드래그(셀 `rowHeight` attr로 저장·라운드트립)
- **표 전체 스케일**: 표 우하단 모서리 hover → 대각 드래그로 종횡비를 고정한 채 표 전체를 균일 배율로 확대/축소
- **표 정렬**: 포매팅 툴바/드래그핸들 메뉴에서 표 전체를 좌/가운데/우 정렬
- **에디터 폭 자동 맞춤**: 에디터보다 넓은 표를 붙여넣으면 열 비율을 유지한 채 폭에 맞춰 축소(가로 스크롤 미제공)

### Excel/Word 붙여넣기

Excel·Word 등에서 복사한 셀 범위를 붙여넣으면(`Ctrl+V`) 이미지가 아닌 **편집 가능한 테이블**로 삽입됩니다. 셀 배경색·글자색·정렬·글자 크기·세로 정렬·굵게/기울임/밑줄이 함께 변환됩니다(글꼴·정확한 hex 색은 10색 팔레트로 근사).

### 테이블 기능 설정

```tsx
<LumirEditor
  tables={{
    splitCells: true,          // 셀 병합/분할 (기본 true)
    cellBackgroundColor: true, // 셀 배경색 (기본 true)
    cellTextColor: true,       // 셀 글자색 (기본 true)
    headers: true,             // 헤더 행/열 (기본 true)
  }}
  tableHandles={true}          // 핸들/코너 스케일 전체 게이트 (기본 true, false면 모두 끔)
/>
```

---

## 2단(다단) 컬럼

노션식 좌우 컬럼 레이아웃입니다. 공식 `@blocknote/xl-multi-column`(AGPL) 대신 **MIT 자체 구현**을 사용합니다.

- **삽입**: 슬래시 메뉴 `/2단 컬럼` 또는 `/2단 컬럼 (구분선)`
- **블록 DnD 생성**: 블록을 다른 블록의 좌/우 가장자리로 끌어다 놓으면 2단 컬럼이 생성됩니다(세로 드롭 인디케이터).
- **구분선**:
  - **블록별**: 생성 시 "구분선" 항목을 고르면 그 블록에 고정되어 저장·라운드트립(`showDivider`).
  - **전역**: `columnDivider` prop(기본 `false`)으로 모든 2단 컬럼 사이에 세로 구분선 표시.

```tsx
<LumirEditor columnDivider />
```

구분선 색·여백은 CSS 변수로 조절합니다: `--lumir-column-divider-color`(기본 `#e5e7eb`), `--lumir-column-grip-space`(기본 `28px`).

---

## 글자 크기

텍스트를 선택한 뒤 **포매팅 툴바** 또는 **상단 고정 툴바(FloatingMenu)** 의 글자 크기 컨트롤로 인라인 크기를 변경합니다.

- **프리셋**: 기본(14px, 스타일 제거) / 10 · 12 · 14 · 16 · 18 · 20 · 24 · 28 (px)
- **1px 스테퍼**: 드롭다운 상단의 `−`/`+` 버튼과 직접 입력(`↑`/`↓` 키)으로 8~96px 범위 내 임의 값 지정. 명시 크기가 없으면 14px 기준으로 증감
- 테이블 셀 텍스트에도 동일하게 적용됩니다.
- 외부 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` ↔ `onContentChange`).
- 외부 렌더러에서 저장 JSON을 직접 다룬다면, 공개 export된 `liftFontSize(blocks)`로 형제 키를 `styles.fontSize`로 복원한 뒤 사용하세요. 반대로 외부로 내보낼 때는 반드시 `lowerFontSize(blocks)`를 거쳐야 합니다(`styles.fontSize`가 유출되면 구버전 소비 앱이 크래시).
- 직렬화 형태 타입은 `SerializedStyledText`로 export됩니다.

---

## 링크

URL을 텍스트에 붙여넣으면 **자동으로 인라인 링크**로 변환됩니다(구버전의 자동 OG 카드 생성은 제거됨).

- **링크 툴바(Notion식)**: 링크에 hover하면 URL 툴팁이 뜨고, 클릭하면 URL·텍스트를 편집하는 popup이 열립니다.
- **보안**: `javascript:`·`data:`·`vbscript:`·`file:` 등 위험 프로토콜은 차단됩니다.
- `linkToolbar` prop(기본 `true`)으로 링크 툴바 표시를 제어합니다.

> 구버전의 `linkPreview` prop과 `/api/link-preview` 서브패스는 **제거**되었습니다.

---

## Placeholder

에디터가 비어있을 때 안내 텍스트를 표시합니다.

```tsx
<LumirEditor placeholder="내용을 입력하세요..." />
```

- 빈 블록에 연한 색으로 표시되고, 입력을 시작하면 사라집니다.
- 모든 빈 블록(첫 블록 포함)에 동일하게 적용됩니다.

---

## 테마

```tsx
<LumirEditor theme="dark" />
<LumirEditor theme="light" />
<LumirEditor theme={{ /* 커스텀 테마 객체 */ }} />
```

`theme`은 `"light"` | `"dark"` | 커스텀 테마 객체를 받습니다(기본 `"light"`).

---

## Props API

### 자주 쓰는 Props

| Prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `initialContent` | `DefaultPartialBlock[] \| string` | `undefined` | 초기 콘텐츠(블록 배열 또는 JSON 문자열) |
| `onContentChange` | `(blocks: DefaultPartialBlock[]) => void` | `undefined` | 콘텐츠 변경 콜백 |
| `s3Upload` | `S3UploaderConfig` | `undefined` | S3 업로드 설정 |
| `uploadFile` | `(file: File) => Promise<string>` | `undefined` | 커스텀 업로드 함수 |
| `onImageDelete` | `(url: string) => void` | `undefined` | 이미지·비디오 삭제 콜백 |
| `onError` | `(error: LumirEditorError) => void` | `undefined` | 에러 콜백 |
| `editable` | `boolean` | `true` | 편집 가능 여부 |
| `placeholder` | `string` | `undefined` | 빈 블록 안내 텍스트 |
| `theme` | `"light" \| "dark" \| object` | `"light"` | 테마 |
| `allowVideoUpload` | `boolean` | `false` | 동영상 업로드 허용 |
| `tables` | `TableConfig` | 모두 `true` | 테이블 기능 |
| `maxImageFileSize` | `number` | `10MB` | 이미지 최대 용량(바이트) |
| `maxVideoFileSize` | `number` | `100MB` | 동영상 최대 용량(바이트) |
| `className` | `string` | `""` | 컨테이너 CSS 클래스 |

### 전체 Props

<details>
<summary>전체 <code>LumirEditorProps</code> 보기</summary>

```ts
interface LumirEditorProps {
  // 콘텐츠
  initialContent?: DefaultPartialBlock[] | string;
  initialEmptyBlocks?: number;          // 초기 빈 블록 개수 (기본 3)
  placeholder?: string;

  // 업로드
  uploadFile?: (file: File) => Promise<string>;
  s3Upload?: S3UploaderConfig;
  allowVideoUpload?: boolean;           // 기본 false
  allowAudioUpload?: boolean;           // 기본 false
  allowFileUpload?: boolean;            // 기본 false
  maxImageFileSize?: number;            // 미설정 시 10MB
  maxVideoFileSize?: number;            // 미설정 시 100MB
  maxAudioFileSize?: number;

  // 기능
  tables?: { splitCells?: boolean; cellBackgroundColor?: boolean; cellTextColor?: boolean; headers?: boolean };
  heading?: { levels?: (1 | 2 | 3 | 4 | 5 | 6)[] };
  defaultStyles?: boolean;              // 기본 true
  disableExtensions?: string[];
  tabBehavior?: "prefer-navigate-ui" | "prefer-indent"; // 기본 "prefer-navigate-ui"
  trailingBlock?: boolean;              // 기본 true
  trailingBlockType?: string;

  // UI
  editable?: boolean;                   // 기본 true
  theme?: "light" | "dark" | Record<string, unknown>; // 기본 "light"
  formattingToolbar?: boolean;          // 기본 true
  linkToolbar?: boolean;                // 기본 true
  sideMenu?: boolean;                   // 기본 true
  sideMenuAddButton?: boolean;          // 기본 false
  slashMenu?: boolean;                  // 기본 true
  emojiPicker?: boolean;                // 기본 true
  filePanel?: boolean;                  // 기본 true
  tableHandles?: boolean;               // 기본 true
  columnDivider?: boolean;              // 2단 컬럼 세로 구분선 (기본 false)
  floatingMenu?: boolean;               // 상단 고정 툴바 (기본 false)
  floatingMenuPosition?: "sticky" | "fixed"; // 기본 "sticky"
  className?: string;

  // 로케일/기타
  locale?: string | LumirLocale;        // "ko" | "en" 등
  resolveFileUrl?: (url: string) => string | Promise<string>;
  fetchLinkPreview?: (url: string) => Promise<LinkPreview> | LinkPreview; // 링크 카드 메타 조회(서버 프록시 권장)
  showErrorToast?: boolean;

  // 콜백
  onContentChange?: (blocks: DefaultPartialBlock[]) => void;
  onError?: (error: LumirEditorError) => void;
  onSelectionChange?: () => void;
  onImageDelete?: (url: string) => void;
  onUploadStart?: (file: File) => void;
  onUploadEnd?: (file: File) => void;
}
```

</details>

### `S3UploaderConfig`

```ts
interface S3UploaderConfig {
  apiEndpoint: string;                  // Presigned URL API 엔드포인트
  env: "development" | "production";
  path: string;                         // S3 저장 경로

  fileNameTransform?: (nameWithoutExt: string, file: File) => string;
  appendUUID?: boolean;                 // 파일명 뒤(확장자 앞)에 UUID 추가
  preserveExtension?: boolean;          // 기본 true. false면 확장자 미부착

  onProgress?: (percent: number) => void; // 0~100, S3 PUT 시만 호출(보간)
  uploadTimeoutMs?: number;             // PUT 타임아웃. 기본 120000
  maxRetries?: number;                  // PUT 재시도. 기본 2
}
```

### React ref (imperative API)

```tsx
import { useRef } from "react";
import { LumirEditor, type LumirEditorReactRef } from "@lumir-company/editor";

const ref = useRef<LumirEditorReactRef>(null);

<LumirEditor ref={ref} />;

ref.current?.getDocument();      // DefaultPartialBlock[] | null
ref.current?.undo();  ref.current?.redo();
ref.current?.canUndo();  ref.current?.canRedo();
ref.current?.insertFile(file);
ref.current?.setTheme("dark");
ref.current?.setEditable(false);
ref.current?.editor;             // 내부 VanillaEditor 인스턴스
```

---

## react-free 코어 API

`mountLumirEditor(host, options)`는 `./core`(또는 루트)에서 import하며 `VanillaEditor` 인스턴스를 반환합니다.

```ts
import { mountLumirEditor, type VanillaEditor } from "@lumir-company/editor/core";

const editor: VanillaEditor = mountLumirEditor(hostEl, options /* LumirEditorProps */);
```

### `VanillaEditor` 인스턴스 메서드

| 메서드 | 반환 | 설명 |
| --- | --- | --- |
| `getDocument()` | `DefaultPartialBlock[]` | 현재 블록 JSON |
| `getHTML()` | `string` | 콘텐츠 HTML |
| `getFullHTML(opts?)` | `string` | `<html>` 문서 HTML(`{ title, style }`) |
| `getMarkdown()` | `string` | 콘텐츠 Markdown |
| `setBlocks(blocks)` | `void` | 블록 교체 |
| `setMarkdown(md)` | `void` | Markdown으로 콘텐츠 설정 |
| `commit()` | `boolean` | 편집 커밋 |
| `undo()` / `redo()` | `boolean` | 실행 취소/재실행 |
| `canUndo()` / `canRedo()` | `boolean` | 가능 여부 |
| `insertFile(file)` | `void` | 파일 삽입(업로드) |
| `isEditable()` / `setEditable(on)` | — | 편집 가능 상태 |
| `setTheme(theme)` | `void` | 테마 변경 |
| `setOnContentChange(fn)` | `void` | 변경 콜백 교체 |
| `destroy()` | `void` | 정리(리스너 해제) |

### 직렬화·변환 유틸

블록 JSON ↔ HTML ↔ Markdown ↔ DOM 변환 함수가 공개 export됩니다.

```ts
import {
  blocksToHtml, blocksToFullHtml, parseHtmlToBlocks,
  blocksToMarkdown, markdownToBlocks,
  blocksToDom, domToBlocks, blockToElement, elementToBlock,
  inlineContentToHtml, htmlToInlineContent, blocksFromText,
} from "@lumir-company/editor/core";

const html = blocksToHtml(blocks);
const blocks2 = parseHtmlToBlocks(html);
const md = blocksToMarkdown(blocks);
```

색상·표 모델(`TableModel`, `newTableModel`, `modelToTableContent` 등) 저수준 유틸도 함께 export됩니다. 전체 목록은 타입 선언(`dist/core.d.ts`)을 참고하세요.

---

## 유틸리티 API

### `ContentUtils`

```tsx
import { ContentUtils } from "@lumir-company/editor";

ContentUtils.isValidJSONString('[{"type":"paragraph"}]'); // boolean
ContentUtils.parseJSONContent(jsonString);                // DefaultPartialBlock[] | null
ContentUtils.createDefaultBlock();                        // DefaultPartialBlock
ContentUtils.validateContent(content, emptyBlockCount);   // DefaultPartialBlock[]
ContentUtils.createEmptyBlocks(3);                        // DefaultPartialBlock[]
```

### 글자 크기 유틸

```tsx
import {
  liftFontSize, lowerFontSize,
  FONT_SIZE_PRESETS, FONT_SIZE_MIN, FONT_SIZE_MAX, FONT_SIZE_DEFAULT_PX, FONT_SIZE_STEP,
  parseFontSizePx, clampFontSizePx, toFontSizeValue,
} from "@lumir-company/editor";
```

### 색상 상수

```tsx
import { TEXT_COLORS, BACKGROUND_COLORS, getHexFromColorValue } from "@lumir-company/editor";
```

### 에러

```tsx
import { LumirEditorError, LUMIR_ERROR_CODES } from "@lumir-company/editor";
// code: "UPLOAD_FAILED" | "INVALID_FILE_TYPE" | "S3_CONFIG_ERROR" | "NETWORK_ERROR" | "CONTENT_PARSE_ERROR" | "UNKNOWN_ERROR"
```

### 로케일

```tsx
import { KO, EN, LOCALES, resolveLocale } from "@lumir-company/editor";
<LumirEditor locale="ko" />; // 또는 locale={EN}
```

### 기타

`createS3Uploader`, `generateUUID`, `cn`(className 결합) 등도 export됩니다.

---

## 스타일링

`@lumir-company/editor/style.css`가 모든 스타일을 단일 번들로 포함합니다(별도 CSS import 불필요).

### 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

에디터 본문은 `.bn-editor`, 블록은 `[data-content-type="..."]` 셀렉터로 접근합니다.

```css
.my-editor .bn-editor {
  padding: 20px 30px;
  font-size: 16px;
  line-height: 1.6;
}
.my-editor [data-content-type="heading"] {
  font-weight: 700;
}
```

```tsx
<LumirEditor className="my-editor" />
```

### CSS 변수

| 변수 | 기본값 | 용도 |
| --- | --- | --- |
| `--lumir-column-divider-color` | `#e5e7eb` | 2단 컬럼 세로 구분선 색 |
| `--lumir-column-grip-space` | `28px` | 컬럼 구분선 양쪽 여백 |
| `--lumir-inactive-selection` | — | blur 상태 선택 하이라이트 색 |

---

## 트러블슈팅

**필수 체크리스트**

- [ ] CSS 임포트: `import "@lumir-company/editor/style.css";`
- [ ] 컨테이너에 높이 지정(부모 요소)
- [ ] Next.js: `dynamic(..., { ssr: false })` 사용
- [ ] React 사용 시 버전 ≥ 18

**에디터가 보이지 않음** → CSS 임포트 누락 또는 컨테이너 높이 미지정.

**Next.js hydration 오류** → `dynamic`으로 `ssr: false` 처리(위 [Next.js](#2-nextjs) 예시).

**이미지 업로드 실패** → `uploadFile` 또는 `s3Upload` 중 하나는 반드시 설정.

**여러 이미지 업로드 시 파일명 중복** → `s3Upload.appendUUID: true`.

---

## 0.4.x → 0.5.0 마이그레이션

**대부분 코드 변경 없이 버전만 올리면 됩니다.** import 경로·컴포넌트·props·타입(`LumirEditorProps`/`DefaultPartialBlock`)·`s3Upload` 계약이 동일합니다. `package.json`에서 버전을 `^0.5.0`으로 올리세요. (엔진 교체가 파괴적이지 않도록 마이너 버전으로 옵트인)

**제거된 기능 — 사용처 정리 필요:**

| 제거 항목 | 조치 |
| --- | --- |
| `linkPreview` prop | 제거. URL 붙여넣기는 이제 항상 인라인 링크로 처리됩니다 |
| `@lumir-company/editor/api/link-preview` 서브패스 | 제거. 해당 API 라우트/import 삭제 |
| `htmlPreview` 블록 · `HtmlPreviewBlock` export | 제거. 저장 JSON에 남아 있어도 로드는 무에러(알 수 없는 블록은 안전하게 폴백) |
| 독립 `FloatingMenu` 컴포넌트 | `<LumirEditor floatingMenu floatingMenuPosition="sticky|fixed" />` prop으로 대체(기존 export는 null 렌더 스텁으로 유지) |
| 독립 `FontSizeButton` 컴포넌트 | 포매팅 툴바에 내장(기존 export는 null 렌더 스텁) |

> 위 제거 항목을 참조하지 않는 프로젝트라면 별도 조치 없이 그대로 동작합니다.

---

## 변경 이력

### v0.5.6 (2026-07-13)

- **여러 줄(크로스블록) 선택 + 일괄 서식**: 블록 경계를 넘는 마우스 드래그로 여러 줄 선택(첫/끝 줄 중간의 부분 선택 포함), 선택 범위 전체에 굵기·색·크기 등 인라인 서식을 한 번에 적용(전 범위 균일 토글 — Word/Notion 정합). Shift+클릭 확장, `Ctrl+B/I/U` 지원. 일괄 서식은 **단일 Undo**로 원복.
- **선택 영역 편집**: 타이핑(선택 대체) · `Backspace`/`Delete`(삭제·블록 병합, 완전 커버 시 빈 블록 정리) · `Enter`(삭제 후 분할) · 붙여넣기(선택 대체) · 부분 복사/잘라내기(무손실 직렬화). 한글(IME) 입력 포함 모든 동작이 **단일 Undo** 단위.
- **`Ctrl/Cmd+A` 전체선택 서식**: 문서 전체 선택 상태에서도 서식 툴바 표시·일괄 적용. 서식 툴바는 화면 세로 1/4 지점에 고정하되 에디터 영역을 벗어나지 않게 클램프.
- v1 범위: 표·2단 컬럼·중첩 목록 경계를 건드리는 선택은 대상에서 제외(기존 동작 보존).

### v0.5.3 (2026-07-07)

- **표 열 리사이즈 폭 튐 수정**: 내부 열 경계 드래그 시 표 전체 폭이 변하던 문제 해결(드래그 시작 시 유효한 `columnWidths`를 재측정 없이 사용, 측정 경로는 병합/colspan 안전한 경계 기반으로 교체, `min-width` 정합).
- **리사이즈 드래그 피드백**: 드래그 중에도 리사이즈 커서·경계 하이라이트 유지(`.resizing`) + 하이라이트가 이동하는 경계를 실시간 추종. 행 리사이즈 후 핸들 재배치 및 최소높이에서 보더·하이라이트 동시 정지.
- **표 폭 스케일 복원**: `fitWidths`가 예산에 정확히 도달하도록 보정 — 표 폭을 줄였다가 다시 늘릴 때 최대폭(최초 채움 폭)으로 복원.
- **에디터 표 폭 정합**: 표가 콘텐츠 영역 좌·우변에 flush(네이티브 표와 정렬).
- **플로팅 툴바 표시 수정**: 표·서식 툴바 `z-index` 상향 — 호스트 앱의 `position:fixed` 컨테이너(예: `z-index:100`) 위에 정상 표시.
- **블록 그립 노출 개선**: 좌측 거터에 마우스를 올려도 해당 블록의 드래그 그립(⠿)이 뜨도록(블록 콘텐츠 밖에서도 노출).
- (구조) 내부 리사이저 세그먼트화 + 행/열 이동 그립·드래그 재정렬 제거.

### v0.5.2 (2026-07-06)

- **링크 카드·임베드 블록(`bookmark`)**: 인라인 링크에 hover → 링크 툴바의 **“카드/임베드” 버튼으로 전환**. 카드는 `fetchLinkPreview(url)` 콜백(서버 프록시 권장)으로 제목·설명·썸네일을 채우고, 임베드는 **YouTube/Vimeo** 만 허용(도메인 화이트리스트 + `sandbox` iframe). 붙여넣기는 기존대로 인라인 링크 유지(부작용 최소).
- **카드/임베드 폭 리사이즈**: 좌/우 핸들 드래그로 `previewWidth` 조절(이미지/비디오와 동일 UX). 임베드는 16:9 비율 유지, 읽기전용·모바일에선 핸들 숨김.
- **상단 고정 툴바(FloatingMenu) 모바일 개선**: 모바일 폭(<440px) 진입 시 기본으로 **펼쳐진 상태**로 표시(접기 토글은 유지).
- **표 전체 선택/해제**: 셀에서 `Ctrl/Cmd+A` 전체 셀 선택, **`Esc` 로 선택 해제**.
- **표 모바일 가로 스크롤**: 모바일(≤640px)에서 표가 편집영역보다 넓으면 열 압축 대신 **가로 스크롤**(데스크톱은 기존 fit-to-width 유지).
- **수정**: 링크 툴바 버튼(편집/카드/임베드) 세로 줄바뀜 교정.
- 커스텀 `bookmark` 블록은 HTML/Markdown 내보내기 시 **링크로 degrade**(라운드트립 안전).

### v0.5.1 (2026-07-03)

- **키보드 블록 내비게이션**: 방향키(←→↑↓)로 블록 간 캐럿 이동, `Ctrl/Cmd+A` 단계 전체선택(블록 텍스트 → 전체 블록 선택 모드: 삭제/복사).
- **코드블록 탈출**: 빈 마지막 줄에서 Enter 2번 → 하단 문단으로 탈출. 후행 개행 렌더 보정.
- **인라인 코드 탈출**: →/← 및 스페이스 2번으로 코드 밖 평문 이어쓰기. Enter 분할 시 빈 `<code>` 잔존 제거.
- **표 셀 방향키**: 상하좌우 셀 이동, 첫 행 ↑·마지막 행 ↓로 표 밖 인접 블록 탈출, 셀 Enter 줄바꿈(`<br>`).
- **디바이더**: 점선 → 검정 실선. **목록 마커**: 절대배치로 커서가 마커 앞에 붙던 문제 교정.

### v0.5.0

- **엔진 교체: BlockNote/ProseMirror/React 런타임 제거 → vanilla(의존성 0) 코어**. 저장 JSON은 BlockNote 형식과 라운드트립 호환 유지.
- **드롭인 대체**: 구 `@lumir-company/editor@0.4.x`와 동일한 컴포넌트·props·타입·`s3Upload` 계약. 코드 변경 없이 버전만 상향.
- **react-free 코어(`./core`) + `mountLumirEditor()`** 공개: 서버 컴포넌트·비-React 환경에서 명령형 사용.
- **직렬화 유틸 공개 export**: 블록 JSON ↔ HTML ↔ Markdown ↔ DOM 변환 함수, 표 모델·색상 유틸.
- **패키징**: ESM + CJS + `.d.ts` + 단일 번들 CSS. `.` / `./react` / `./core` / `./style.css` 서브패스.
- **제거**: `linkPreview`(prop·`/api/link-preview` 서브패스), `htmlPreview` 블록/`HtmlPreviewBlock`.
- **링크 동작 변경**: URL 붙여넣기 → 항상 인라인 링크, Notion식 링크 툴바(hover 툴팁 → 편집 popup).

<details>
<summary>0.4.x 이력 보기</summary>

### v0.4.23 (2026-06-22)
- 다중 블록 선택 시 글자 크기 스테퍼 누적 버그 수정(`readSelectionFontSize` + 낙관적 갱신).
- 툴바 드롭다운 조작 시 선택 하이라이트 유지(`InactiveSelectionExtension`, `--lumir-inactive-selection`).

### v0.4.22 (2026-06-22)
- 글자 크기 1px 스테퍼 + 직접 입력(8~96px) 추가. `FONT_SIZE_MIN/MAX/DEFAULT_PX/STEP`, `parseFontSizePx`/`clampFontSizePx`/`toFontSizeValue` 공개.
- 글자 크기 스타일을 동기 스펙으로 변경(적용 직후 툴바가 (0,0)으로 튀는 문제 수정).

### v0.4.21 (2026-06-18)
- Word/docx 표 붙여넣기 개선: 에디터 폭 자동 맞춤, 셀 서식(글자 크기·세로 정렬 포함) 보존 확대.

### v0.4.20 (2026-06-18)
- 2단 컬럼 구분선을 생성 시 블록별로 선택·고정(`showDivider` 라운드트립).
- 표 셀에서 `Ctrl/Cmd+A` → 표 전체 선택(재입력 시 문서 전체로 확장).

### v0.4.19 (2026-06-17)
- 2단 컬럼 중앙 세로 구분선 전역 옵션(`columnDivider`), CSS 변수 제어.
- 표 전체 종횡비 고정 스케일(우하단 코너 드래그), `tableHandles`로 게이트.

### v0.4.18 (2026-06-17)
- 표 열 삭제 버그 수정(병합 셀·첫 열 포함), 병합 셀 collapse 시 행 높이 보존.

### v0.4.17 (2026-06-16)
- 표 행 높이 리사이즈(`rowHeight` 라운드트립), 표 블록 정렬(좌/가운데/우), 표 하단 여백 축소.
- 2단(다단) 컬럼 레이아웃 신규(슬래시 메뉴·블록 DnD, MIT 자체 구현).

### v0.4.16 (2026-06-05)
- 인라인 글자 크기(기본 + 10~28px 8단계), 구버전 호환 형제 키 직렬화, `liftFontSize`/`lowerFontSize`·`SerializedStyledText` 공개.
- `floatingMenu` 사용 시 중복 팝업 툴바 억제.

### v0.4.15 (2026-06-05)
- Notion 스타일 테이블 셀 색상·정렬·포커스 핸들(`LumirTableHandlesController`), 다중 셀 일괄 색 적용.

### v0.4.14 (2026-05-29)
- Excel/스프레드시트 셀 붙여넣기 → 편집 가능한 테이블(클립보드 `<table>` 우선 파싱, 서식 매핑).

### v0.4.13 (2026-04-03)
- `@tiptap/core` 외부화로 중복 인스턴스 런타임 에러 수정.

### v0.4.12 (2026-04-03)
- Numbered/Bullet List 글자 크기 14px로 통일.

### v0.4.10 (2026-03-18)
- 업로드 진행률 보간 표시(`onProgress` 0→…→100 부드럽게 갱신).

### v0.4.9 (2026-03-17)
- `maxImageFileSize`/`maxVideoFileSize` 추가.

### v0.4.5 (2026-03-06)
- 이미지·동영상 업로드 문서화(형식/용량/삽입 경로/삭제 콜백/에러 처리).

### v0.4.3 (2026-02-23)
- 이미지 삭제 콜백(`onImageDelete`) 추가, `placeholder` prop 추가, URL 프로토콜 보안 강화.

### v0.4.2 (2026-02-23)
- `LumirEditorError` 커스텀 에러 클래스·`onError` 콜백 추가, 코드 구조 리팩토링.

### v0.4.1 (2026-01-15)
- `preserveExtension` 추가. **Breaking**: `fileNameTransform`이 확장자 제외 이름(`nameWithoutExt`)을 전달.

### v0.4.0 (2026-01-15)
- `fileNameTransform`·`appendUUID` 추가, 다중 이미지 업로드 중복 해결.

</details>

---

## 라이선스

MIT
