@lumir-company/editor 0.4.22 → 0.4.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,1698 +1,1728 @@
1
- # LumirEditor
2
-
3
- **이미지 전용** BlockNote 기반 Rich Text 에디터
4
-
5
- [![npm version](https://img.shields.io/npm/v/@lumir-company/editor.svg)](https://www.npmjs.com/package/@lumir-company/editor)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
-
8
- > 이미지 업로드에 최적화된 경량 에디터. S3 업로드, 파일명 커스터마이징, 로딩 스피너 내장.
9
-
10
- ---
11
-
12
- ## 목차
13
-
14
- - [특징](#특징)
15
- - [빠른 시작](#빠른-시작)
16
- - [이미지 업로드](#이미지-업로드)
17
- - [S3 업로드 설정](#1-s3-업로드-권장)
18
- - [파일명 커스터마이징](#파일명-커스터마이징)
19
- - [커스텀 업로더](#2-커스텀-업로더)
20
- - [동영상 업로드 및 임베딩](#동영상-업로드-및-임베딩)
21
- - [업로드 진행률 표시](#업로드-진행률-표시-이미지동영상-공통)
22
- - [이미지·동영상 업로드 상세 가이드](#이미지동영상-업로드-상세-가이드)
23
- - [이미지·비디오 삭제](#이미지비디오-삭제)
24
- - [테이블](#테이블)
25
- - [글자 크기](#글자-크기)
26
- - [HTML 미리보기](#html-미리보기)
27
- - [Placeholder](#placeholder)
28
- - [링크 프리뷰](#링크-프리뷰)
29
- - [Props API](#props-api)
30
- - [사용 예제](#사용-예제)
31
- - [스타일링](#스타일링)
32
- - [트러블슈팅](#트러블슈팅)
33
-
34
- ---
35
-
36
- ## 특징
37
-
38
- | 특징 | 설명 |
39
- | ----------------------- | -------------------------------------------------------------------------------- |
40
- | **이미지 전용** | 이미지 업로드/드래그앤드롭 기본 지원 (비디오는 `allowVideoUpload`로 옵션 활성화) |
41
- | **HTML 미리보기** | HTML 파일을 드래그 앤 드롭하여 iframe으로 미리보기 |
42
- | **S3 연동** | Presigned URL 기반 S3 업로드 내장 |
43
- | **파일명 커스터마이징** | 업로드 파일명 변경 콜백 + UUID 자동 추가 지원 |
44
- | **로딩 스피너** | 이미지 업로드 중 자동 스피너 표시 |
45
- | **테이블** | Notion 스타일 행·열·셀 grip 핸들, 셀 배경색, Excel 셀 붙여넣기 지원 |
46
- | **글자 크기** | 인라인 글자 크기 변경 (프리셋 8단계 + 기본), 구버전 호환 직렬화 |
47
- | **성능 최적화** | 애니메이션 비활성화로 빠른 렌더링 |
48
- | **TypeScript** | 완전한 타입 안전성 |
49
- | **테마 지원** | 라이트/다크 테마 및 커스텀 테마 |
50
-
51
- ### 지원 이미지 형식
52
-
53
- ```
54
- PNG, JPEG/JPG, GIF, WebP, BMP
55
- ```
56
-
57
- ---
58
-
59
- ## 빠른 시작
60
-
61
- ### 1. 설치
62
-
63
- ```bash
64
- npm install @lumir-company/editor
65
- # 또는
66
- yarn add @lumir-company/editor
67
- ```
68
-
69
- **필수 Peer Dependencies:**
70
-
71
- - `react` >= 18.0.0
72
- - `react-dom` >= 18.0.0
73
-
74
- ### 2. 기본 사용
75
-
76
- ```tsx
77
- import { LumirEditor } from "@lumir-company/editor";
78
- import "@lumir-company/editor/style.css"; // 필수!
79
-
80
- export default function App() {
81
- return (
82
- <div className="w-full h-[500px]">
83
- <LumirEditor onContentChange={(blocks) => console.log(blocks)} />
84
- </div>
85
- );
86
- }
87
- ```
88
-
89
- > **중요**: `style.css`를 임포트하지 않으면 에디터가 정상 작동하지 않습니다.
90
-
91
- ### 3. Next.js에서 사용
92
-
93
- ```tsx
94
- "use client";
95
-
96
- import dynamic from "next/dynamic";
97
- import "@lumir-company/editor/style.css";
98
-
99
- // SSR 비활성화 필수
100
- const LumirEditor = dynamic(
101
- () =>
102
- import("@lumir-company/editor").then((m) => ({ default: m.LumirEditor })),
103
- { ssr: false },
104
- );
105
-
106
- export default function EditorPage() {
107
- return (
108
- <div className="h-[500px]">
109
- <LumirEditor />
110
- </div>
111
- );
112
- }
113
- ```
114
-
115
- ---
116
-
117
- ## 이미지 업로드
118
-
119
- ### 1. S3 업로드 (권장)
120
-
121
- Presigned URL을 사용한 안전한 S3 업로드 방식입니다.
122
-
123
- ```tsx
124
- <LumirEditor
125
- s3Upload={{
126
- apiEndpoint: "/api/s3/presigned",
127
- env: "production",
128
- path: "blog/images",
129
- }}
130
- />
131
- ```
132
-
133
- #### S3 파일 저장 경로
134
-
135
- ```
136
- {env}/{path}/{filename}
137
-
138
- 예시:
139
- production/blog/images/my-photo.png
140
- ```
141
-
142
- #### API 엔드포인트 응답 형식
143
-
144
- 서버는 다음 형식으로 응답해야 합니다:
145
-
146
- ```json
147
- {
148
- "presignedUrl": "https://s3.amazonaws.com/bucket/upload-url",
149
- "publicUrl": "https://cdn.example.com/production/blog/images/my-photo.png"
150
- }
151
- ```
152
-
153
- 클라이언트는 `apiEndpoint?key={파일키}&contentType={MIME}` 형태로 GET 요청을 보내고, 서버는 위 형식으로 JSON을 반환하면 됩니다.
154
-
155
- #### S3 Presigned URL API 구현 예시
156
-
157
- **Next.js (App Router)**
158
-
159
- 파일: `app/api/s3/presigned/route.ts`
160
-
161
- ```typescript
162
- import { NextRequest, NextResponse } from "next/server";
163
- import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
164
- import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
165
-
166
- const s3 = new S3Client({
167
- region: process.env.AWS_REGION!,
168
- credentials: {
169
- accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
170
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
171
- },
172
- });
173
-
174
- export async function GET(req: NextRequest) {
175
- const { searchParams } = new URL(req.url);
176
- const key = searchParams.get("key");
177
- const contentType = searchParams.get("contentType");
178
-
179
- if (!key) {
180
- return NextResponse.json({ error: "key is required" }, { status: 400 });
181
- }
182
-
183
- const command = new PutObjectCommand({
184
- Bucket: process.env.AWS_S3_BUCKET!,
185
- Key: key,
186
- ContentType: contentType || "application/octet-stream",
187
- });
188
-
189
- const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 60 });
190
- const publicUrl = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
191
-
192
- return NextResponse.json({ presignedUrl, publicUrl, key });
193
- }
194
- ```
195
-
196
- 필요한 환경 변수: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET`
197
-
198
- **Next.js가 아닌 프로젝트에서 사용하기**
199
-
200
- 동일하게 **GET** 요청으로 `key`, `contentType` 쿼리 파라미터를 받아 `presignedUrl`, `publicUrl`을 JSON으로 반환하는 엔드포인트를 구현하면 됩니다.
201
-
202
- - **Express (Node.js)**
203
-
204
- ```javascript
205
- const express = require("express");
206
- const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
207
- const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
208
-
209
- const s3 = new S3Client({
210
- region: process.env.AWS_REGION,
211
- credentials: {
212
- accessKeyId: process.env.AWS_ACCESS_KEY_ID,
213
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
214
- },
215
- });
216
-
217
- app.get("/api/s3/presigned", async (req, res) => {
218
- const key = req.query.key;
219
- const contentType = req.query.contentType || "application/octet-stream";
220
-
221
- if (!key) {
222
- return res.status(400).json({ error: "key is required" });
223
- }
224
-
225
- const command = new PutObjectCommand({
226
- Bucket: process.env.AWS_S3_BUCKET,
227
- Key: key,
228
- ContentType: contentType,
229
- });
230
-
231
- const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 60 });
232
- const publicUrl = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
233
-
234
- res.json({ presignedUrl, publicUrl, key });
235
- });
236
- ```
237
-
238
- 에디터 사용 시 `apiEndpoint`만 해당 서버 주소로 맞추면 됩니다.
239
-
240
- ```tsx
241
- <LumirEditor
242
- s3Upload={{
243
- apiEndpoint: "https://api.myapp.com/api/s3/presigned",
244
- env: "production",
245
- path: "uploads",
246
- }}
247
- />
248
- ```
249
-
250
- - **Remix / SvelteKit / 기타 프레임워크**
251
- GET 라우트에서 `key`, `contentType`을 받아 `@aws-sdk/client-s3`의 `PutObjectCommand`와 `@aws-sdk/s3-request-presigner`의 `getSignedUrl`로 presigned URL을 생성한 뒤, `{ presignedUrl, publicUrl, key }` 형태로 JSON 응답하면 동일하게 사용할 수 있습니다. CORS가 필요한 경우 해당 도메인을 허용해 두세요.
252
-
253
- ---
254
-
255
- ### 파일명 커스터마이징
256
-
257
- 여러 이미지를 동시에 업로드할 때 파일명 중복을 방지하고 관리하기 쉽게 만드는 기능입니다.
258
-
259
- > **참고**: 기본적으로 확장자는 자동으로 붙습니다. `preserveExtension: false`로 설정하면 확장자를 붙이지 않습니다.
260
-
261
- #### 옵션 1: UUID 자동 추가
262
-
263
- ```tsx
264
- <LumirEditor
265
- s3Upload={{
266
- apiEndpoint: "/api/s3/presigned",
267
- env: "production",
268
- path: "uploads",
269
- appendUUID: true, // 파일명 뒤에 UUID 자동 추가
270
- }}
271
- />
272
- ```
273
-
274
- **결과:**
275
-
276
- ```
277
- 원본: photo.png
278
- 업로드: photo_550e8400-e29b-41d4-a716-446655440000.png
279
- ```
280
-
281
- #### 옵션 2: 파일명 변환 콜백
282
-
283
- ```tsx
284
- <LumirEditor
285
- s3Upload={{
286
- apiEndpoint: "/api/s3/presigned",
287
- env: "production",
288
- path: "uploads",
289
- fileNameTransform: (nameWithoutExt, file) => {
290
- // nameWithoutExt는 확장자가 제거된 파일명 (예: "photo")
291
- // 확장자는 자동으로 붙습니다
292
- const userId = getCurrentUserId();
293
- return `${userId}_${nameWithoutExt}`;
294
- },
295
- }}
296
- />
297
- ```
298
-
299
- **결과:**
300
-
301
- ```
302
- 원본: photo.png
303
- → nameWithoutExt: "photo"
304
- → 변환 후: "user123_photo"
305
- → 최종: user123_photo.png
306
- ```
307
-
308
- #### 옵션 3: 조합 사용 (권장)
309
-
310
- ```tsx
311
- <LumirEditor
312
- s3Upload={{
313
- apiEndpoint: "/api/s3/presigned",
314
- env: "production",
315
- path: "uploads",
316
- fileNameTransform: (nameWithoutExt) => `user123_${nameWithoutExt}`,
317
- appendUUID: true, // 변환 후 UUID 추가
318
- }}
319
- />
320
- ```
321
-
322
- **결과:**
323
-
324
- ```
325
- 원본: photo.png
326
- → nameWithoutExt: "photo"
327
- 1. fileNameTransform 적용: "user123_photo"
328
- 2. appendUUID 적용: "user123_photo_550e8400-e29b-41d4"
329
- 3. 확장자 붙이기: user123_photo_550e8400-e29b-41d4.png
330
- ```
331
-
332
- #### 실전 예제: 타임스탬프 + UUID
333
-
334
- ```tsx
335
- function MyEditor() {
336
- return (
337
- <LumirEditor
338
- s3Upload={{
339
- apiEndpoint: "/api/s3/presigned",
340
- env: "production",
341
- path: "uploads",
342
- fileNameTransform: (nameWithoutExt, file) => {
343
- // nameWithoutExt는 이미 확장자가 제거됨
344
- const timestamp = new Date().toISOString().split("T")[0]; // 2024-01-15
345
- return `${timestamp}_${nameWithoutExt}`;
346
- },
347
- appendUUID: true,
348
- }}
349
- />
350
- );
351
- }
352
- ```
353
-
354
- **결과:**
355
-
356
- ```
357
- 원본: photo.png
358
- → nameWithoutExt: "photo"
359
- 1. fileNameTransform: "2024-01-15_photo"
360
- 2. appendUUID: "2024-01-15_photo_550e8400-e29b-41d4"
361
- 3. 확장자 붙이기: 2024-01-15_photo_550e8400-e29b-41d4.png
362
- ```
363
-
364
- #### 옵션 4: 확장자 제거 (preserveExtension: false)
365
-
366
- ```tsx
367
- <LumirEditor
368
- s3Upload={{
369
- apiEndpoint: "/api/s3/presigned",
370
- env: "production",
371
- path: "uploads",
372
- fileNameTransform: (nameWithoutExt) => `${nameWithoutExt}_custom`,
373
- preserveExtension: false, // 확장자 안 붙임
374
- }}
375
- />
376
- ```
377
-
378
- **결과:**
379
-
380
- ```
381
- 원본: photo.png
382
- → nameWithoutExt: "photo"
383
- → 변환 후: "photo_custom"
384
- → 최종: photo_custom (확장자 없음)
385
- ```
386
-
387
- **사용 사례**: WebP 변환 등 서버에서 확장자를 변경하는 경우
388
-
389
- ```tsx
390
- fileNameTransform: (nameWithoutExt) => `${nameWithoutExt}.webp`,
391
- preserveExtension: false,
392
- ```
393
-
394
- ---
395
-
396
- ### 2. 커스텀 업로더
397
-
398
- 자체 업로드 로직을 사용할 때:
399
-
400
- ```tsx
401
- <LumirEditor
402
- uploadFile={async (file) => {
403
- const formData = new FormData();
404
- formData.append("image", file);
405
-
406
- const response = await fetch("/api/upload", {
407
- method: "POST",
408
- body: formData,
409
- });
410
-
411
- const { url } = await response.json();
412
- return url; // 업로드된 이미지 URL 반환
413
- }}
414
- />
415
- ```
416
-
417
- ### 3. 헬퍼 함수 사용
418
-
419
- ```tsx
420
- import { createS3Uploader } from "@lumir-company/editor";
421
-
422
- const s3Uploader = createS3Uploader({
423
- apiEndpoint: "/api/s3/presigned",
424
- env: "production",
425
- path: "images",
426
- appendUUID: true,
427
- });
428
-
429
- // 에디터에 적용
430
- <LumirEditor uploadFile={s3Uploader} />;
431
-
432
- // 또는 별도로 사용
433
- const imageUrl = await s3Uploader(imageFile);
434
- ```
435
-
436
- ### 업로드 우선순위
437
-
438
- 1. `uploadFile` prop이 있으면 우선 사용
439
- 2. `uploadFile` 없고 `s3Upload`가 있으면 S3 업로드 사용
440
- 3. 둘 다 없으면 업로드 실패
441
-
442
- ---
443
-
444
- ## 동영상 업로드 및 임베딩
445
-
446
- `allowVideoUpload={true}`로 설정하면 동영상 업로드와 에디터 내 재생이 가능합니다. S3/`uploadFile`은 이미지와 동일한 설정을 사용하며, 동영상은 최대 100MB까지 허용됩니다.
447
-
448
- ### 동영상 업로드 활성화
449
-
450
- ```tsx
451
- <LumirEditor
452
- allowVideoUpload={true}
453
- s3Upload={{
454
- apiEndpoint: "/api/s3/presigned",
455
- env: "production",
456
- path: "videos",
457
- appendUUID: true,
458
- }}
459
- />
460
- ```
461
-
462
- - **지원 형식**: MP4, WebM, OGG
463
- - **삽입 경로**: 붙여넣기, 드래그 앤 드롭, 슬래시 메뉴("Video"), FloatingMenu 이미지/동영상 버튼
464
-
465
- ### 업로드 진행률 표시 (이미지·동영상 공통)
466
-
467
- S3 업로드 시 `s3Upload.onProgress` 콜백을 지정하면 업로드 진행률(0~100%)을 받을 수 있습니다. 에디터는 내부적으로 이 값을 사용해 업로드 중 툴바에 **"n%"** 를 표시합니다. 동영상처럼 대용량 파일은 브라우저가 `progress` 이벤트를 자주 보내지 않을 수 있어, 내부적으로 **보간 로직**을 적용해 0→100만 보이지 않고 중간 진행률이 부드럽게 갱신되도록 했습니다.
468
-
469
- ```tsx
470
- <LumirEditor
471
- allowVideoUpload={true}
472
- s3Upload={{
473
- apiEndpoint: "/api/s3/presigned",
474
- env: "production",
475
- path: "videos",
476
- appendUUID: true,
477
- onProgress: (percent) => {
478
- console.log(`업로드 진행률: ${percent}%`);
479
- // 에디터 기본 UI에 이미 표시되며, 필요 시 자체 프로그레스 바 등에 연동 가능
480
- },
481
- }}
482
- />
483
- ```
484
-
485
- - **동작**: S3 PUT 요청 시에만 호출됩니다. Presigned URL 요청 단계에서는 호출되지 않습니다.
486
- - **표시**: `onProgress`를 넘기면 에디터 툴바에 `n%`가 자동 표시되며, 업로드 완료 시 숨겨집니다.
487
-
488
- ### 데이터 내부 동영상 임베딩
489
-
490
- 동영상 블록은 `initialContent` / `onContentChange`에 포함됩니다. 저장 시 `{ type: "video", props: { url: "..." } }` 형태로 블록이 유지됩니다.
491
-
492
- - **재생**: 화면에서 동영상을 재생하려면 `allowVideoUpload={true}`로 두어야 합니다. 이렇게 해야 video 확장이 활성화되어 BlockNote 기본 플레이어가 렌더링됩니다.
493
- - `allowVideoUpload={false}`인 상태에서 initialContent에 video 블록만 넣으면 데이터는 보존되지만, 재생 UI는 비활성화된 확장 때문에 표시되지 않을 수 있습니다.
494
- - **지원 URL**: 비디오 블록의 `url`은 **직접 재생 가능한 비디오 파일 URL**만 지원합니다(예: S3에 업로드된 `.mp4`, `.webm`, `.ogg`). YouTube·Vimeo 등 스트리밍 페이지 URL(`youtube.com/watch?v=...` 등)은 `<video>` 요소의 `src`로 재생되지 않으므로, 해당 링크를 video 블록 URL로 넣으면 재생되지 않습니다. YouTube 임베드가 필요하면 별도 embed 블록 또는 iframe 삽입 방식을 고려해야 합니다.
495
-
496
- ---
497
-
498
- ## 이미지·동영상 업로드 상세 가이드
499
-
500
- 이미지와 동영상 업로드 기능을 함께 쓰는 방법을 단계별로 정리했습니다.
501
-
502
- ### 1. 개요
503
-
504
- | 구분 | 이미지 | 동영상 |
505
- | --------------- | ------------------------------- | ------------------------------------------ |
506
- | **기본 동작** | 업로드 항상 사용 가능 (설정 시) | `allowVideoUpload={true}`일 때만 사용 가능 |
507
- | **최대 용량** | 10MB | 100MB |
508
- | **업로드 설정** | `s3Upload` 또는 `uploadFile` | 이미지와 동일한 설정 공유 |
509
-
510
- - 이미지만 쓸 때: `s3Upload` 또는 `uploadFile`만 설정하면 됩니다.
511
- - 이미지 + 동영상: 위 설정에 더해 `allowVideoUpload={true}`를 넣습니다.
512
-
513
- ### 2. 지원 형식 및 제한
514
-
515
- **이미지**
516
-
517
- - **MIME**: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/bmp`
518
- - **확장자**: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp`
519
- - **용량**: 기본 최대 10MB. `maxImageFileSize`(바이트)로 변경 가능.
520
- - **제외**: SVG (XSS 방지로 업로드 불가)
521
-
522
- **동영상** (`allowVideoUpload={true}`일 때)
523
-
524
- - **MIME**: `video/mp4`, `video/webm`, `video/ogg`, `video/quicktime`
525
- - **확장자**: `.mp4`, `.webm`, `.ogg`, `.mov`
526
- - **용량**: 기본 최대 100MB. `maxVideoFileSize`(바이트)로 변경 가능.
527
-
528
- **업로드 타임아웃**: S3 업로드 시 PUT 요청 타임아웃은 `s3Upload.uploadTimeoutMs`로 설정합니다. 미설정 시 120초(120000ms)가 적용됩니다.
529
-
530
- **용량 및 타임아웃 사용자 설정**
531
-
532
- 이미지·동영상 최대 용량과 S3 PUT 타임아웃을 props로 변경할 수 있습니다. 미설정 시 기본값(이미지 10MB, 동영상 100MB, 타임아웃 120초)이 적용됩니다.
533
-
534
- ```tsx
535
- <LumirEditor
536
- allowVideoUpload={true}
537
- maxImageFileSize={5 * 1024 * 1024} // 5MB
538
- maxVideoFileSize={200 * 1024 * 1024} // 200MB
539
- s3Upload={{
540
- apiEndpoint: "/api/s3/presigned",
541
- env: "production",
542
- path: "uploads",
543
- uploadTimeoutMs: 180000, // 180초 (대용량 동영상 시 연장)
544
- }}
545
- />
546
- ```
547
-
548
- ### 3. 삽입 경로 (공통)
549
-
550
- 이미지·동영상 모두 아래 경로로 삽입할 수 있습니다.
551
-
552
- | 경로 | 설명 |
553
- | ------------------ | ----------------------------------------------------------------- |
554
- | **붙여넣기** | 클립보드 이미지/동영상 → Ctrl+V (또는 Cmd+V) |
555
- | **드래그 앤 드롭** | 파일을 에디터 영역으로 끌어다 놓기 |
556
- | **슬래시 메뉴** | `/` 입력 후 "Image" 또는 "Video"(동영상 허용 시) 선택 → 파일 선택 |
557
- | **FloatingMenu** | 툴바의 이미지/동영상 버튼 클릭 → 파일 선택 |
558
-
559
- 동영상은 `allowVideoUpload={true}`일 때만 슬래시 메뉴에 "Video"가 보이고, FloatingMenu에서도 동영상 파일을 선택할 수 있습니다.
560
-
561
- ### 4. 설정 방법 요약
562
-
563
- **우선순위**
564
-
565
- 1. `uploadFile` prop이 있으면 → 해당 함수로 업로드 (이미지·동영상 동일)
566
- 2. `uploadFile` 없고 `s3Upload`가 있으면 → Presigned URL 기반 S3 업로드
567
- 3. 둘 다 없으면 → 업로드 시 에러 (에디터는 동작하지만 파일 삽입 불가)
568
-
569
- **이미지만 사용하는 경우**
570
-
571
- ```tsx
572
- <LumirEditor
573
- s3Upload={{
574
- apiEndpoint: "/api/s3/presigned",
575
- env: "production",
576
- path: "images",
577
- appendUUID: true,
578
- }}
579
- onContentChange={(blocks) => setContent(blocks)}
580
- />
581
- ```
582
-
583
- **이미지 + 동영상 사용하는 경우**
584
-
585
- ```tsx
586
- <LumirEditor
587
- allowVideoUpload={true}
588
- s3Upload={{
589
- apiEndpoint: "/api/s3/presigned",
590
- env: "production",
591
- path: "images",
592
- appendUUID: true,
593
- }}
594
- onContentChange={(blocks) => setContent(blocks)}
595
- />
596
- ```
597
-
598
- 동영상은 같은 `s3Upload`로 업로드됩니다. 서버에서 이미지와 동영상을 다른 경로에 두고 싶다면 아래처럼 `fileNameTransform`으로 prefix를 분리하면 됩니다.
599
-
600
- **이미지·동영상 업로드 경로 분리 (fileNameTransform)**
601
-
602
- `fileNameTransform`의 두 번째 인자 `file`로 이미지/동영상을 구분해, 파일명 앞에 폴더 prefix를 붙이면 됩니다. 최종 S3 키는 `{env}/{path}/{filename}` 이므로, `filename`에 `images/...` / `videos/...` 를 넣으면 경로가 나뉩니다.
603
-
604
- ```tsx
605
- <LumirEditor
606
- allowVideoUpload={true}
607
- s3Upload={{
608
- apiEndpoint: "/api/s3/presigned",
609
- env: "production",
610
- path: "uploads", // 공통 상위 경로
611
- appendUUID: true,
612
- fileNameTransform: (nameWithoutExt, file) => {
613
- const isVideo = file.type.startsWith("video/");
614
- return `${isVideo ? "videos" : "images"}/${nameWithoutExt}`;
615
- },
616
- }}
617
- />
618
- ```
619
-
620
- 결과 예: 이미지 → `production/uploads/images/photo_abc123.png`, 동영상 → `production/uploads/videos/clip_def456.mp4`
621
-
622
- **커스텀 업로더로 이미지·동영상 통합**
623
-
624
- ```tsx
625
- <LumirEditor
626
- allowVideoUpload={true}
627
- uploadFile={async (file) => {
628
- const formData = new FormData();
629
- formData.append("file", file);
630
- const res = await fetch("/api/upload", { method: "POST", body: formData });
631
- const { url } = await res.json();
632
- return url;
633
- }}
634
- />
635
- ```
636
-
637
- `uploadFile`은 이미지/동영상 구분 없이 `File`을 받아 업로드 후 **공개 URL 문자열**을 반환하면 됩니다.
638
-
639
- ### 5. 저장 데이터 구조
640
-
641
- `onContentChange` / `initialContent`에서 사용하는 블록 형태입니다.
642
-
643
- **이미지 블록**
644
-
645
- ```json
646
- {
647
- "type": "image",
648
- "props": {
649
- "url": "https://your-cdn.com/images/photo_xxx.png",
650
- "caption": "",
651
- "previewWidth": 512
652
- },
653
- "content": [],
654
- "children": []
655
- }
656
- ```
657
-
658
- **동영상 블록**
659
-
660
- ```json
661
- {
662
- "type": "video",
663
- "props": {
664
- "url": "https://your-cdn.com/videos/clip_xxx.mp4"
665
- },
666
- "content": [],
667
- "children": []
668
- }
669
- ```
670
-
671
- `url`은 반드시 **브라우저에서 직접 재생 가능한 URL**이어야 합니다. 동영상은 YouTube/Vimeo 링크가 아니라, 업로드 후 받은 `.mp4` 등 직접 재생 URL만 지원합니다.
672
-
673
- ### 6. 삭제 시 콜백
674
-
675
- 이미지·동영상 블록을 에디터에서 삭제하면 `onImageDelete`가 호출됩니다. 인자는 삭제된 미디어의 URL입니다.
676
-
677
- ```tsx
678
- <LumirEditor
679
- s3Upload={{ ... }}
680
- allowVideoUpload={true}
681
- onImageDelete={(url) => {
682
- // url은 이미지 또는 동영상 URL
683
- console.log("삭제됨:", url);
684
- // S3/스토리지에서 삭제 API 호출
685
- }}
686
- />
687
- ```
688
-
689
- Undo로 블록을 복원해도 이미 삭제 API를 호출했다면 서버 상태와 불일치할 수 있으므로, 지연 삭제(예: 5분 후 삭제) 패턴을 권장합니다. 자세한 예시는 [이미지·비디오 삭제](#이미지비디오-삭제)를 참고하세요.
690
-
691
- ### 7. 에러 처리
692
-
693
- - **지원하지 않는 형식**: 업로드 시 `LumirEditorError`가 발생할 수 있으며, `onError`로 처리할 수 있습니다.
694
- - **용량 초과**: 기본 한도(이미지 10MB·동영상 100MB)를 넘으면 업로드가 거부됩니다. `maxImageFileSize`, `maxVideoFileSize`로 한도를 변경할 수 있습니다.
695
- - **업로드 실패**: `uploadFile` 또는 S3 업로드에서 예외가 나면 해당 파일만 삽입되지 않고, 콘솔에 경고가 출력됩니다.
696
-
697
- ```tsx
698
- <LumirEditor
699
- s3Upload={{ ... }}
700
- onError={(err) => {
701
- console.error("에디터 에러:", err);
702
- // 토스트 등으로 사용자 알림
703
- }}
704
- />
705
- ```
706
-
707
- ---
708
-
709
- ## 이미지·비디오 삭제
710
-
711
- 에디터에서 **이미지 또는 비디오**가 삭제될 때 S3 등 외부 스토리지에서도 자동으로 삭제하고 싶다면 `onImageDelete` 콜백을 사용하세요. 이미지 블록과 비디오 블록 삭제 시 모두 호출됩니다.
712
-
713
- ### 기본 사용
714
-
715
- ```tsx
716
- <LumirEditor
717
- s3Upload={{
718
- apiEndpoint: "/api/s3/presigned",
719
- env: "production",
720
- path: "images",
721
- }}
722
- onImageDelete={(imageUrl) => {
723
- console.log("미디어(이미지/비디오) 삭제됨:", imageUrl);
724
- // S3에서 삭제 로직 구현
725
- }}
726
- />
727
- ```
728
-
729
- ### 권장: 지연 삭제 (Undo/Redo 대응)
730
-
731
- Undo로 이미지·비디오를 복원할 수 있도록 **지연 삭제**를 권장합니다.
732
-
733
- ```tsx
734
- "use client";
735
-
736
- import { useState, useRef, useCallback } from "react";
737
-
738
- function Editor() {
739
- const pendingDeletes = useRef(new Map());
740
-
741
- const handleImageDelete = useCallback((imageUrl: string) => {
742
- // 이미 예약된 삭제가 있으면 무시
743
- if (pendingDeletes.current.has(imageUrl)) return;
744
-
745
- // 5분 후 삭제 예약
746
- const timeoutId = setTimeout(
747
- async () => {
748
- pendingDeletes.current.delete(imageUrl);
749
-
750
- // S3에서 실제 삭제
751
- await fetch(`/api/s3/delete?url=${encodeURIComponent(imageUrl)}`, {
752
- method: "DELETE",
753
- });
754
- },
755
- 5 * 60 * 1000,
756
- ); // 5분
757
-
758
- pendingDeletes.current.set(imageUrl, timeoutId);
759
- }, []);
760
-
761
- return (
762
- <LumirEditor
763
- s3Upload={
764
- {
765
- /* ... */
766
- }
767
- }
768
- onImageDelete={handleImageDelete}
769
- />
770
- );
771
- }
772
- ```
773
-
774
- ### S3 삭제 API 예시
775
-
776
- > **참고**: `onImageDelete`는 **프레임워크 독립적**이며, 이미지와 비디오 삭제 시 모두 호출됩니다. 아래는 각 환경별 구현 예시입니다.
777
-
778
- #### Next.js API Route
779
-
780
- **파일**: `app/api/s3/delete/route.ts`
781
-
782
- ```typescript
783
- import { NextRequest, NextResponse } from "next/server";
784
- import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";
785
-
786
- const s3 = new S3Client({
787
- region: process.env.AWS_REGION!,
788
- credentials: {
789
- accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
790
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
791
- },
792
- });
793
-
794
- export async function DELETE(req: NextRequest) {
795
- const { searchParams } = new URL(req.url);
796
- const imageUrl = searchParams.get("url");
797
-
798
- if (!imageUrl) {
799
- return NextResponse.json({ error: "url is required" }, { status: 400 });
800
- }
801
-
802
- // URL에서 S3 키 추출
803
- const key = extractKeyFromUrl(imageUrl);
804
-
805
- await s3.send(
806
- new DeleteObjectCommand({
807
- Bucket: process.env.AWS_S3_BUCKET!,
808
- Key: key,
809
- }),
810
- );
811
-
812
- return NextResponse.json({ success: true });
813
- }
814
-
815
- function extractKeyFromUrl(url: string): string {
816
- const urlObj = new URL(url);
817
- return decodeURIComponent(urlObj.pathname.slice(1));
818
- }
819
- ```
820
-
821
- **클라이언트 구현**:
822
-
823
- ```tsx
824
- const handleImageDelete = (imageUrl: string) => {
825
- fetch(`/api/s3/delete?url=${encodeURIComponent(imageUrl)}`, {
826
- method: "DELETE",
827
- });
828
- };
829
-
830
- <LumirEditor onImageDelete={handleImageDelete} />;
831
- ```
832
-
833
- #### React + Express
834
-
835
- **서버** (`server.js`):
836
-
837
- ```javascript
838
- app.delete("/api/images", async (req, res) => {
839
- const { imageUrl } = req.body;
840
- const key = extractKeyFromS3Url(imageUrl);
841
-
842
- await s3Client.send(
843
- new DeleteObjectCommand({
844
- Bucket: process.env.S3_BUCKET,
845
- Key: key,
846
- }),
847
- );
848
-
849
- res.json({ success: true });
850
- });
851
- ```
852
-
853
- **클라이언트**:
854
-
855
- ```tsx
856
- const handleImageDelete = async (imageUrl: string) => {
857
- await fetch("https://api.myapp.com/api/images", {
858
- method: "DELETE",
859
- headers: { "Content-Type": "application/json" },
860
- body: JSON.stringify({ imageUrl }),
861
- });
862
- };
863
-
864
- <LumirEditor onImageDelete={handleImageDelete} />;
865
- ```
866
-
867
- #### React Native + Firebase Storage
868
-
869
- ```tsx
870
- import storage from "@react-native-firebase/storage";
871
-
872
- const handleImageDelete = async (imageUrl: string) => {
873
- const ref = storage().refFromURL(imageUrl);
874
- await ref.delete();
875
- };
876
-
877
- <LumirEditor onImageDelete={handleImageDelete} />;
878
- ```
879
-
880
- #### Vue + Axios + FastAPI
881
-
882
- ```typescript
883
- const handleImageDelete = async (imageUrl: string) => {
884
- await axios.delete("https://api.myapp.com/v1/images", {
885
- data: { imageUrl },
886
- });
887
- };
888
- ```
889
-
890
- ### 주의사항
891
-
892
- | 항목 | 설명 |
893
- | --------------- | --------------------------------------------- |
894
- | **Undo/Redo** | 지연 삭제로 복원 가능하게 구현 (권장: 5-10분) |
895
- | **권한 검증** | 프로덕션에서는 인증/인가 필수 |
896
- | **참조 카운트** | 같은 이미지를 여러 문서에서 사용하는지 확인 |
897
- | **삭제 로그** | 감사 추적을 위한 삭제 기록 저장 권장 |
898
-
899
- ---
900
-
901
- ## 테이블
902
-
903
- 슬래시 메뉴(`/`)에서 "Table"을 선택하거나 Excel/스프레드시트 셀을 붙여넣어 테이블을 만들 수 있습니다.
904
-
905
- ### Notion 스타일 grip 핸들
906
-
907
- 셀에 포커스하면 셀 주변에 핸들(grip)이 표시됩니다.
908
-
909
- | 위치 | 동작 |
910
- | ------------- | --------------------------------------------------------------------- |
911
- | **상단 grip** | 클릭 → 열 메뉴 (열 삭제, 왼쪽/오른쪽에 열 추가, 색) / 드래그 → 열 이동 |
912
- | **좌측 grip** | 클릭 → 행 메뉴 (행 삭제, 위/아래에 행 추가, 색) / 드래그 → 행 이동 |
913
- | **우측 grip** | hover → 셀 메뉴 (셀 배경색 등) |
914
-
915
- - 행/열 메뉴가 열리면 해당 행/열 전체가 하이라이트됩니다
916
- - 표 우측/하단 가장자리 hover 시 행/열 추가 버튼이 표시됩니다
917
-
918
- ### 셀 배경색·정렬
919
-
920
- - **단일 셀**: 우측 grip 또는 행/열 메뉴의 "색" 항목에서 셀 배경색 적용
921
- - **다중 셀**: 셀을 드래그로 범위 선택한 뒤 플로팅 툴바의 색상 버튼으로 일괄 적용
922
- - **정렬**: 셀 선택 후 툴바의 정렬 버튼으로 셀 단위 텍스트 정렬 적용
923
-
924
- ### Excel/스프레드시트 붙여넣기
925
-
926
- Excel 등에서 복사한 셀 범위를 붙여넣으면(`Ctrl+V`) 이미지가 아닌 **편집 가능한 테이블**로 삽입됩니다. 셀 배경색, 글자색, 정렬, 굵게/기울임/밑줄 서식이 함께 변환됩니다.
927
-
928
- ### 테이블 기능 설정 (`tables` prop)
929
-
930
- ```tsx
931
- <LumirEditor
932
- tables={{
933
- splitCells: true, // 셀 병합/분할 (기본: true)
934
- cellBackgroundColor: true, // 셀 배경색 (기본: true)
935
- cellTextColor: true, // 셀 글자색 (기본: true)
936
- headers: true, // 헤더 행/열 (기본: true)
937
- }}
938
- />
939
- ```
940
-
941
- 테이블 핸들 UI 전체를 끄려면 `tableHandles={false}`를 사용합니다.
942
-
943
- ---
944
-
945
- ## 글자 크기
946
-
947
- 텍스트를 선택한 뒤 **포매팅 툴바** 또는 **상단 고정 툴바(FloatingMenu)** 의 글자 크기 드롭다운으로 인라인 글자 크기를 변경할 수 있습니다.
948
-
949
- - 프리셋: **기본**(14px, 스타일 제거) / 10 / 12 / 14 / 16 / 18 / 20 / 24 / 28 (px)
950
- - 테이블 셀 내부 텍스트에도 동일하게 적용됩니다 (인라인 스타일)
951
- - 외부 HTML(웹페이지·Excel 등)을 붙여넣을 때의 글자 크기는 가져오지 않습니다
952
-
953
- ### 하위호환 직렬화 포맷 (중요)
954
-
955
- 글자 크기는 저장 JSON에서 `styles` 맵이 아닌 **styled-text의 형제(sibling) 키 `fontSize`** 로 직렬화됩니다:
956
-
957
- ```json
958
- {
959
- "type": "paragraph",
960
- "content": [
961
- { "type": "text", "text": "큰 글씨", "styles": { "bold": true }, "fontSize": "18px" }
962
- ]
963
- }
964
- ```
965
-
966
- 이유: BlockNote는 `styles` 맵에 스키마에 없는 스타일 키가 있으면 예외를 던지므로,
967
- `styles.fontSize`로 저장하면 **fontSize 스펙이 없는 구버전 SDK(≤0.4.15)가 해당 JSON을
968
- `initialContent`로 로드할 때 에디터가 크래시**합니다. 형제 키 방식은 구버전에서
969
- 조용히 무시되어(글자 크기만 미표시) 안전하게 로드됩니다.
970
-
971
- - 에디터 로드/저장 시 변환은 자동입니다 (`initialContent` ↔ `onContentChange`)
972
- - BlockNote 외부 렌더러에서 저장 JSON을 직접 렌더링한다면, 공개 export된
973
- `liftFontSize(blocks)`로 형제 키를 `styles.fontSize`로 복원한 뒤 사용하세요
974
- - 직렬화 형태 타입은 `SerializedStyledText`로 export됩니다
975
-
976
- > ⚠️ 에디터 블록 JSON을 외부로 내보내는 새 경로를 추가할 경우 반드시
977
- > `lowerFontSize`를 거쳐야 합니다. `styles.fontSize`가 저장 JSON에 유출되면
978
- > 구버전 소비 앱이 크래시합니다.
979
-
980
- ---
981
-
982
- ## HTML 미리보기
983
-
984
- LumirEditor는 HTML 파일을 iframe을 사용하여 미리보기할 수 있는 커스텀 블록을 제공합니다. 편집 불가능한 순수 미리보기 기능으로, HTML 문서를 안전하게 표시할 수 있습니다.
985
-
986
- ### 사용 방법
987
-
988
- #### 1. 드래그 앤 드롭
989
-
990
- HTML 파일(`.html`, `.htm`)을 에디터에 드래그 앤 드롭하면 자동으로 iframe 미리보기 블록이 삽입됩니다.
991
-
992
- ```tsx
993
- <LumirEditor />
994
- ```
995
-
996
- - **지원 파일 형식**: `.html`, `.htm`
997
- - **특징**:
998
- - 편집 불가능한 순수 미리보기
999
- - 접기/펼치기 기능
1000
- - 안전한 sandbox 처리 (`allow-scripts`·`allow-same-origin` 미허용 → JavaScript 실행 및 부모 페이지 접근 차단)
1001
- - 파일명 표시
1002
-
1003
- #### 2. 슬래시 메뉴
1004
-
1005
- 에디터에서 `/`를 입력하고 "HTML Preview"를 선택하면 예제 HTML 미리보기 블록이 삽입됩니다.
1006
-
1007
- ```
1008
- / → HTML Preview
1009
- ```
1010
-
1011
- ### 특징
1012
-
1013
- - **iframe 기반**: HTML 문서를 독립된 iframe에서 안전하게 렌더링
1014
- - **Sandbox 보안**: `sandbox="allow-popups allow-forms"` — `allow-scripts`와 `allow-same-origin`을 의도적으로 제외하여 JavaScript 실행과 부모 페이지 접근을 차단
1015
- - **접기/펼치기**: 헤더 클릭으로 미리보기 영역 토글
1016
- - **드래그 리사이즈**: 하단 핸들을 드래그하여 높이 조절 가능 (100px ~ 1200px)
1017
- - **새 창 열기**: HTML 문서를 새 창에서 전체 화면으로 확인
1018
- - **다운로드**: HTML 파일로 다운로드
1019
- - **편집 불가**: 순수 미리보기 전용
1020
-
1021
- ### 사용 예제
1022
-
1023
- ```tsx
1024
- import { LumirEditor } from "@lumir-company/editor";
1025
- import "@lumir-company/editor/style.css";
1026
-
1027
- function App() {
1028
- return (
1029
- <div className="w-full h-[600px]">
1030
- <LumirEditor
1031
- onContentChange={(blocks) => {
1032
- // HTML 미리보기 블록도 일반 블록과 동일하게 처리됨
1033
- console.log(blocks);
1034
- }}
1035
- />
1036
- </div>
1037
- );
1038
- }
1039
- ```
1040
-
1041
- ### 프로그래밍 방식으로 블록 삽입
1042
-
1043
- ```tsx
1044
- import { HtmlPreviewBlock } from "@lumir-company/editor";
1045
-
1046
- // 에디터 인스턴스에서 직접 블록 삽입
1047
- editor.insertBlocks([
1048
- {
1049
- type: "htmlPreview",
1050
- props: {
1051
- htmlContent: "<h1>Hello World</h1><p>This is HTML content</p>",
1052
- fileName: "example.html",
1053
- height: "400px",
1054
- },
1055
- },
1056
- ]);
1057
- ```
1058
-
1059
- ### 주의사항
1060
-
1061
- - HTML 내용은 iframe의 `sandbox="allow-popups allow-forms"` 속성으로 보안이 강화되어 있습니다 (`allow-scripts`·`allow-same-origin` 미허용)
1062
- - **JavaScript는 의도적으로 비활성화**되어 있습니다 (보안상 이유)
1063
- - 외부 리소스(CSS, JS, 이미지 등)는 상대 경로가 작동하지 않을 수 있습니다
1064
- - 인라인 CSS 스타일을 권장합니다
1065
-
1066
- ---
1067
-
1068
- ## Placeholder
1069
-
1070
- 에디터가 비어있을 때 사용자에게 안내 텍스트를 표시합니다.
1071
-
1072
- ### 사용 방법
1073
-
1074
- ```tsx
1075
- <LumirEditor placeholder="내용을 입력하세요..." />
1076
- ```
1077
-
1078
- - 빈 블록에 연한 색상으로 안내 텍스트가 표시됩니다
1079
- - 사용자가 입력을 시작하면 자동으로 사라집니다
1080
- - 모든 빈 블록(첫 번째 블록 포함)에 동일한 텍스트가 적용됩니다
1081
-
1082
- ---
1083
-
1084
- ## 링크 프리뷰
1085
-
1086
- URL을 붙여넣거나 슬래시 메뉴에서 선택하면 Open Graph 카드를 표시합니다.
1087
-
1088
- > **서버 사이드 필수**: 링크 프리뷰는 외부 사이트의 OG 메타데이터를 가져오기 위해 **서버 사이드 API 라우트**가 필요합니다. 브라우저의 CORS 정책으로 인해 클라이언트에서 직접 외부 HTML을 가져올 수 없습니다.
1089
-
1090
- ### 사용 조건
1091
-
1092
- | 조건 | 설명 |
1093
- | -------------------------- | -------------------------------------------------------------------------- |
1094
- | **서버 환경** | Next.js, Remix, SvelteKit 등 서버 사이드 라우팅을 지원하는 프레임워크 필요 |
1095
- | **API 라우트** | 패키지 내장 핸들러를 re-export하는 1줄짜리 파일 필요 |
1096
- | **순수 React (CRA, Vite)** | 별도 백엔드 서버 없이는 사용 불가 |
1097
-
1098
- ### 설정 방법 (Next.js App Router)
1099
-
1100
- **1단계: API 라우트 생성** (1줄)
1101
-
1102
- ```ts
1103
- // src/app/api/link-preview/route.ts
1104
- export { linkPreviewHandler as GET } from "@lumir-company/editor/api/link-preview";
1105
- ```
1106
-
1107
- 패키지에 내장된 핸들러를 re-export하므로 별도 로직 작성이 불필요합니다.
1108
-
1109
- **2단계: 에디터에 linkPreview prop 설정**
1110
-
1111
- ```tsx
1112
- <LumirEditor linkPreview={{ apiEndpoint: "/api/link-preview" }} />
1113
- ```
1114
-
1115
- ### 설정 방법 (Remix / SvelteKit)
1116
-
1117
- 패키지의 `GET` 핸들러는 표준 Web API(`Request`/`Response`)를 사용하므로 Remix, SvelteKit 등에서도 동일하게 사용 가능합니다.
1118
-
1119
- ```ts
1120
- // Remix: app/routes/api.link-preview.ts
1121
- export { linkPreviewHandler as loader } from "@lumir-company/editor/api/link-preview";
1122
- ```
1123
-
1124
- ### 설정 방법 (Express / Fastify 등 커스텀 서버)
1125
-
1126
- 패키지에서 `fetchUrlMetadata`와 `parseMetaTags`를 import하여 직접 라우트를 구현할 수 있습니다.
1127
-
1128
- ```ts
1129
- import { fetchUrlMetadata } from "@lumir-company/editor/api/link-preview";
1130
-
1131
- app.get("/api/link-preview", async (req, res) => {
1132
- const url = req.query.url as string;
1133
- if (!url) return res.status(400).json({ error: "url required" });
1134
-
1135
- try {
1136
- const metadata = await fetchUrlMetadata(url);
1137
- res.json(metadata);
1138
- } catch {
1139
- res.status(500).json({ error: "Failed to fetch metadata" });
1140
- }
1141
- });
1142
- ```
1143
-
1144
- ### 주요 기능
1145
-
1146
- - URL 붙여넣기 시 자동 링크 프리뷰 블록 생성
1147
- - 슬래시 메뉴(`/`)에서 Link Preview 항목 선택
1148
- - 드래그 리사이즈 (좌우 너비, 하단 이미지 높이)
1149
- - 텍스트 링크를 링크 프리뷰로 전환 (링크 툴바 버튼)
1150
- - 메타데이터에 이미지 없으면 이미지 영역 자동 생략
1151
- - 에러 카드 클릭 시 링크 이동
1152
-
1153
- ### 내장 API 핸들러 export 목록
1154
-
1155
- `@lumir-company/editor/api/link-preview`에서 export되는 항목:
1156
-
1157
- | Export | 타입 | 설명 |
1158
- | -------------------- | ------------------------------------------------- | ------------------------------------------------ |
1159
- | `linkPreviewHandler` | `(request: Request) => Promise<Response>` | 링크 프리뷰 메타데이터 조회 핸들러 (re-export용) |
1160
- | `fetchUrlMetadata` | `(url: string) => Promise<LinkMetadata>` | 서버에서 직접 메타데이터 조회 |
1161
- | `parseMetaTags` | `(html: string, baseUrl: string) => LinkMetadata` | HTML에서 OG 메타데이터 파싱 |
1162
- | `LinkMetadata` | `interface` | 메타데이터 타입 정의 |
1163
-
1164
- ---
1165
-
1166
- ## Props API
1167
-
1168
- ### 핵심 Props
1169
-
1170
- | Prop | 타입 | 기본값 | 설명 |
1171
- | ------------------ | ----------------------------------- | ----------- | ----------------------------------------- |
1172
- | `s3Upload` | `S3UploaderConfig` | `undefined` | S3 업로드 설정 |
1173
- | `uploadFile` | `(file: File) => Promise<string>` | `undefined` | 커스텀 업로드 함수 |
1174
- | `onContentChange` | `(blocks) => void` | `undefined` | 콘텐츠 변경 콜백 |
1175
- | `onImageDelete` | `(imageUrl: string) => void` | `undefined` | 이미지·비디오 삭제 시 콜백 |
1176
- | `onError` | `(error: LumirEditorError) => void` | `undefined` | 에러 발생 시 콜백 |
1177
- | `initialContent` | `Block[] \| string` | `undefined` | 초기 콘텐츠 |
1178
- | `editable` | `boolean` | `true` | 편집 가능 여부 |
1179
- | `placeholder` | `string` | `undefined` | 빈 블록 안내 텍스트 |
1180
- | `linkPreview` | `{ apiEndpoint: string }` | `undefined` | 링크 프리뷰 설정 |
1181
- | `theme` | `"light" \| "dark" \| ThemeObject` | `"light"` | 테마 (커스텀 테마 객체 지원) |
1182
- | `allowVideoUpload` | `boolean` | `false` | 동영상 업로드 허용 |
1183
- | `tables` | `TableConfig` | 모두 `true` | 테이블 기능 설정 ([테이블](#테이블) 참고) |
1184
- | `className` | `string` | `""` | CSS 클래스 |
1185
- | `maxImageFileSize` | `number` | `undefined` | 이미지 최대 용량(바이트). 미설정 시 10MB |
1186
- | `maxVideoFileSize` | `number` | `undefined` | 동영상 최대 용량(바이트). 미설정 시 100MB |
1187
-
1188
- ### S3UploaderConfig
1189
-
1190
- ```tsx
1191
- interface S3UploaderConfig {
1192
- // 필수
1193
- apiEndpoint: string; // Presigned URL API 엔드포인트
1194
- env: "development" | "production";
1195
- path: string; // S3 저장 경로
1196
-
1197
- // 선택 (파일명 커스터마이징)
1198
- fileNameTransform?: (nameWithoutExt: string, file: File) => string; // 확장자 제외한 이름 변환
1199
- appendUUID?: boolean; // true: 파일명 뒤에 UUID 추가 (확장자 앞에 삽입)
1200
- preserveExtension?: boolean; // false: 확장자를 붙이지 않음 (기본: true)
1201
-
1202
- // 선택 (업로드 동작)
1203
- onProgress?: (percent: number) => void; // 업로드 진행률 0~100 콜백 (S3 PUT 시만 호출, 중간 진행률 보간 지원)
1204
- uploadTimeoutMs?: number; // PUT 타임아웃(ms). 미설정 시 120000(120초). 대용량 동영상 시 연장 권장
1205
- maxRetries?: number; // PUT 실패 시 재시도 횟수. 기본 2(최대 3회 시도)
1206
- }
1207
- ```
1208
-
1209
- ### 전체 Props
1210
-
1211
- <details>
1212
- <summary>전체 Props 보기</summary>
1213
-
1214
- ```tsx
1215
- interface LumirEditorProps {
1216
- // === 에디터 설정 ===
1217
- initialContent?: DefaultPartialBlock[] | string; // 초기 콘텐츠 (블록 배열 또는 JSON 문자열)
1218
- initialEmptyBlocks?: number; // 초기 빈 블록 개수 (기본: 3)
1219
- placeholder?: string; // 빈 블록에 표시할 안내 텍스트 (예: "내용을 입력하세요...")
1220
- uploadFile?: (file: File) => Promise<string>; // 커스텀 파일 업로드 함수
1221
- s3Upload?: {
1222
- apiEndpoint: string;
1223
- env: "development" | "production";
1224
- path: string;
1225
- fileNameTransform?: (nameWithoutExt: string, file: File) => string; // 확장자 제외한 이름 변환
1226
- appendUUID?: boolean; // UUID 자동 추가 (확장자 앞)
1227
- preserveExtension?: boolean; // 확장자 자동 붙이기 (기본: true)
1228
- onProgress?: (percent: number) => void; // 업로드 진행률 0~100 (이미지·동영상 공통, 중간 진행률 보간)
1229
- uploadTimeoutMs?: number; // PUT 타임아웃(ms). 기본 120000
1230
- maxRetries?: number; // PUT 재시도 횟수. 기본 2
1231
- };
1232
-
1233
- // === 콜백 ===
1234
- onContentChange?: (blocks: DefaultPartialBlock[]) => void; // 콘텐츠 변경 시 호출
1235
- onImageDelete?: (imageUrl: string) => void; // 이미지·비디오 삭제 시 호출 (S3 삭제 등)
1236
- onSelectionChange?: () => void; // 선택 영역 변경 시 호출
1237
- onError?: (error: LumirEditorError) => void; // 에러 발생 시 호출
1238
-
1239
- // 기능 설정
1240
- tables?: {
1241
- splitCells?: boolean; // 셀 병합/분할 (기본: true)
1242
- cellBackgroundColor?: boolean; // 셀 배경색 (기본: true)
1243
- cellTextColor?: boolean; // 셀 글자색 (기본: true)
1244
- headers?: boolean; // 헤더 행/열 (기본: true)
1245
- };
1246
- heading?: { levels?: (1 | 2 | 3 | 4 | 5 | 6)[] }; // 헤딩 레벨 설정 (기본: [1,2,3,4,5,6])
1247
- defaultStyles?: boolean; // 기본 스타일 활성화 (기본: true)
1248
- disableExtensions?: string[]; // 비활성화할 확장 기능 목록
1249
- tabBehavior?: "prefer-navigate-ui" | "prefer-indent"; // 탭 키 동작 (기본: "prefer-navigate-ui")
1250
- trailingBlock?: boolean; // 마지막에 빈 블록 자동 추가 (기본: true)
1251
-
1252
- // === UI 설정 ===
1253
- editable?: boolean; // 편집 가능 여부 (기본: true)
1254
- theme?: "light" | "dark" | ThemeObject | { light: ThemeObject; dark: ThemeObject }; // 에디터 테마 (기본: "light")
1255
- formattingToolbar?: boolean; // 서식 툴바 표시 (기본: true)
1256
- linkToolbar?: boolean; // 링크 툴바 표시 (기본: true)
1257
- sideMenu?: boolean; // 사이드 메뉴 표시 (기본: true)
1258
- sideMenuAddButton?: boolean; // 사이드 메뉴 + 버튼 표시 (기본: false, 드래그 핸들만 표시)
1259
- emojiPicker?: boolean; // 이모지 선택기 표시 (기본: true)
1260
- filePanel?: boolean; // 파일 패널 표시 (기본: true)
1261
- tableHandles?: boolean; // 테이블 핸들 표시 (기본: true)
1262
- floatingMenu?: boolean; // 상단 고정 플로팅 메뉴 표시 (기본: false)
1263
- floatingMenuPosition?: "sticky" | "fixed"; // 플로팅 메뉴 위치 (기본: "sticky")
1264
- columnDivider?: boolean; // 2단(다단) 컬럼 사이 중앙 세로 구분선 표시 (기본: false)
1265
- className?: string; // 컨테이너 CSS 클래스
1266
-
1267
- // === 링크 프리뷰 설정 ===
1268
- linkPreview?: {
1269
- apiEndpoint: string; // 링크 메타데이터를 가져올 API 엔드포인트 (예: "/api/link-preview")
1270
- };
1271
-
1272
- // 미디어 업로드 허용 여부 (기본: 모두 비활성)
1273
- allowVideoUpload?: boolean; // 비디오 업로드 허용 (기본: false)
1274
- allowAudioUpload?: boolean; // 오디오 업로드 허용 (기본: false)
1275
- allowFileUpload?: boolean; // 일반 파일 업로드 허용 (기본: false)
1276
- maxImageFileSize?: number; // 이미지 최대 파일 크기(바이트). 미설정 시 10MB
1277
- maxVideoFileSize?: number; // 동영상 최대 파일 크기(바이트). 미설정 시 100MB
1278
- }
1279
- ```
1280
-
1281
- </details>
1282
-
1283
- ---
1284
-
1285
- ## 사용 예제
1286
-
1287
- ### 읽기 전용 모드
1288
-
1289
- ```tsx
1290
- <LumirEditor
1291
- editable={false}
1292
- initialContent={savedContent}
1293
- sideMenu={false}
1294
- formattingToolbar={false}
1295
- />
1296
- ```
1297
-
1298
- ### 다크 테마
1299
-
1300
- ```tsx
1301
- <LumirEditor theme="dark" className="bg-gray-900 rounded-lg" />
1302
- ```
1303
-
1304
- ### 콘텐츠 저장 및 불러오기
1305
-
1306
- ```tsx
1307
- import { useState, useEffect } from "react";
1308
- import { LumirEditor, ContentUtils } from "@lumir-company/editor";
1309
-
1310
- function EditorWithSave() {
1311
- const [content, setContent] = useState("");
1312
-
1313
- // 불러오기
1314
- useEffect(() => {
1315
- const saved = localStorage.getItem("content");
1316
- if (saved && ContentUtils.isValidJSONString(saved)) {
1317
- setContent(saved);
1318
- }
1319
- }, []);
1320
-
1321
- // 저장
1322
- const handleChange = (blocks) => {
1323
- const json = JSON.stringify(blocks);
1324
- localStorage.setItem("content", json);
1325
- };
1326
-
1327
- return (
1328
- <LumirEditor initialContent={content} onContentChange={handleChange} />
1329
- );
1330
- }
1331
- ```
1332
-
1333
- ---
1334
-
1335
- ## 스타일링
1336
-
1337
- ### Tailwind CSS와 함께 사용
1338
-
1339
- ```tsx
1340
- import { LumirEditor, cn } from "@lumir-company/editor";
1341
-
1342
- <LumirEditor
1343
- className={cn(
1344
- "min-h-[400px] rounded-xl",
1345
- "border border-gray-200 shadow-lg",
1346
- "focus-within:ring-2 focus-within:ring-blue-500",
1347
- )}
1348
- />;
1349
- ```
1350
-
1351
- ### 커스텀 스타일
1352
-
1353
- ```css
1354
- /* globals.css */
1355
- .my-editor .bn-editor {
1356
- padding: 20px 30px;
1357
- font-size: 16px;
1358
- line-height: 1.6;
1359
- }
1360
-
1361
- .my-editor [data-content-type="heading"] {
1362
- font-weight: 700;
1363
- margin-top: 24px;
1364
- }
1365
- ```
1366
-
1367
- ```tsx
1368
- <LumirEditor className="my-editor" />
1369
- ```
1370
-
1371
- ---
1372
-
1373
- ## 트러블슈팅
1374
-
1375
- ### 필수 체크리스트
1376
-
1377
- - [ ] CSS 임포트: `import "@lumir-company/editor/style.css"`
1378
- - [ ] 컨테이너 높이 설정: 부모 요소에 높이 지정 필수
1379
- - [ ] Next.js: `dynamic(..., { ssr: false })` 사용
1380
- - [ ] React 버전: 18.0.0 이상
1381
-
1382
- ### 자주 발생하는 문제
1383
-
1384
- #### 1. 에디터가 보이지 않음
1385
-
1386
- ```tsx
1387
- // 잘못됨
1388
- <LumirEditor />;
1389
-
1390
- // 올바름
1391
- import "@lumir-company/editor/style.css";
1392
- <div className="h-[400px]">
1393
- <LumirEditor />
1394
- </div>;
1395
- ```
1396
-
1397
- #### 2. Next.js Hydration 오류
1398
-
1399
- ```tsx
1400
- // 잘못됨
1401
- import { LumirEditor } from "@lumir-company/editor";
1402
-
1403
- // 올바름
1404
- const LumirEditor = dynamic(
1405
- () =>
1406
- import("@lumir-company/editor").then((m) => ({ default: m.LumirEditor })),
1407
- { ssr: false },
1408
- );
1409
- ```
1410
-
1411
- #### 3. 이미지 업로드 실패
1412
-
1413
- ```tsx
1414
- // uploadFile 또는 s3Upload 중 하나는 반드시 설정!
1415
- <LumirEditor
1416
- s3Upload={{
1417
- apiEndpoint: "/api/s3/presigned",
1418
- env: "development",
1419
- path: "images",
1420
- }}
1421
- />
1422
- ```
1423
-
1424
- #### 4. 여러 이미지 업로드 시 중복 문제
1425
-
1426
- ```tsx
1427
- // 해결: appendUUID 사용
1428
- <LumirEditor
1429
- s3Upload={{
1430
- apiEndpoint: "/api/s3/presigned",
1431
- env: "production",
1432
- path: "images",
1433
- appendUUID: true, // 고유한 파일명 보장
1434
- }}
1435
- />
1436
- ```
1437
-
1438
- ---
1439
-
1440
- ## 유틸리티 API
1441
-
1442
- ### ContentUtils
1443
-
1444
- ```tsx
1445
- import { ContentUtils } from "@lumir-company/editor";
1446
-
1447
- // JSON 검증
1448
- ContentUtils.isValidJSONString('[{"type":"paragraph"}]'); // true
1449
-
1450
- // JSON 파싱
1451
- const blocks = ContentUtils.parseJSONContent(jsonString);
1452
-
1453
- // 기본 블록 생성
1454
- const emptyBlock = ContentUtils.createDefaultBlock();
1455
- ```
1456
-
1457
- ### createS3Uploader
1458
-
1459
- ```tsx
1460
- import { createS3Uploader } from "@lumir-company/editor";
1461
-
1462
- const uploader = createS3Uploader({
1463
- apiEndpoint: "/api/s3/presigned",
1464
- env: "production",
1465
- path: "uploads",
1466
- appendUUID: true,
1467
- });
1468
-
1469
- // 직접 사용
1470
- const url = await uploader(imageFile);
1471
- ```
1472
-
1473
- ## 관련 링크
1474
-
1475
- - [npm Package](https://www.npmjs.com/package/@lumir-company/editor)
1476
- - [BlockNote Documentation](https://www.blocknotejs.org/)
1477
-
1478
- ---
1479
-
1480
- ## 변경 로그
1481
-
1482
- ### v0.4.22 (2026-06-22)
1483
-
1484
- - **글자 크기 1px 단위 조절** *(신규)*
1485
- - 글자 크기 드롭다운(포매팅 툴바·플로팅 메뉴) 상단에 **−/+ 스테퍼 + 직접 입력** 추가 프리셋 사이 값(13·15·17·21px…)을 1px 단위로 지정 (`↑`/`↓` 키로도 증감)
1486
- - 허용 범위 **8~96px**(범위 밖 입력은 자동 보정), 명시 크기가 없으면 14px 기준으로 증감. 스테퍼 클릭 시 드롭다운은 닫히지 않아 연속 조절 가능
1487
- - 기존 프리셋(10·12·14·16·18·20·24·28px)·"기본" 리셋은 그대로 유지. 저장 JSON·하위호환(형제 키 직렬화)은 변경 없음
1488
- - 공개 API에 `FONT_SIZE_MIN`/`FONT_SIZE_MAX`/`FONT_SIZE_DEFAULT_PX`/`FONT_SIZE_STEP` 및 `parseFontSizePx`/`clampFontSizePx`/`toFontSizeValue` 추가
1489
- - **글자 크기 스타일을 동기 스펙(`createStyleSpec`)으로 변경**: 기존 React 스펙은 span 내용이 비동기 렌더되어, 크기 적용 직후 포매팅 툴바가 좌상단으로 튀는 문제가 있었음(선택 영역 DOM 좌표가 순간 (0,0)으로 측정됨). 동기 렌더로 바꿔 툴바가 선택 위치에 고정됨. HTML 직렬화(`data-style-type`/`data-value`)는 동일
1490
-
1491
- ### v0.4.21 (2026-06-18)
1492
-
1493
- - **Word/docx 붙여넣기 품질 개선** *(신규)*
1494
- - **에디터 너비 자동 맞춤**: 에디터보다 넓은 표를 붙여넣으면 비율을 유지한 에디터 너비에 맞춰 축소(가로 스크롤 방지). 너비를 colgroup/셀(pt/px/%)에서 읽어 `columnWidths`로 적용
1495
- - **셀 서식 보존 확대**: 기존(굵게·기울임·밑줄·취소선·글자색·배경색·텍스트 정렬)에 더해 **글자 크기**와 **세로 정렬**(middle/bottom)도 보존
1496
- - 한계: 글자 크기는 셀 단위 균일 적용, 글꼴(font-family)·정확한 hex 색은 BlockNote 미지원(색은 10색 팔레트 근사)
1497
-
1498
- ### v0.4.20 (2026-06-18)
1499
-
1500
- - **2단 컬럼 구분선 생성 선택** *(신규)*
1501
- - 슬래시 메뉴에서 `2단 컬럼` / `2단 컬럼 (구분선)` 골라 삽입 선택한 구분선 유무가 그 블록에 **고정**(문서에 `showDivider`로 저장·라운드트립). 구분선 있는/없는 컬럼을 섞어 사용 가능
1502
- - v0.4.19의 전역 `columnDivider` prop과 별개로, **블록별**로 구분선을 지정
1503
- - **표 셀에서 Ctrl/Cmd+A → 표 전체 선택** *(신규)*
1504
- - 셀에 포커스(커서/선택) 시 Ctrl/Cmd+A로 표의 모든 셀을 한 번에 선택(일괄 색·정렬·삭제 등에 유용)
1505
- - 이미 전체가 선택된 상태에서 다시 누르면 문서 전체로 단계적 확장. 표 밖에서는 기본 동작 유지
1506
-
1507
- ### v0.4.19 (2026-06-17)
1508
-
1509
- - **2단 컬럼 중앙 세로 구분선 (`columnDivider` 옵션)** *(신규)*
1510
- - `columnDivider` prop(기본 `false`)으로 2단(다단) 컬럼 사이 중앙에 세로 구분선 표시
1511
- - 구분선 양쪽에 드래그 핸들(grip) 너비만큼 여백을 둬 핸들과 겹치지 않음
1512
- - 색·여백을 CSS 변수로 조절: `--lumir-column-divider-color`(기본 `#e5e7eb`), `--lumir-column-grip-space`(기본 28px)
1513
- - **표 전체 종횡비 고정 스케일 (코너 드래그)** *(신규)*
1514
- - 우하단 모서리 hover 대각 리사이즈 커서 → 드래그로 표 전체를 종횡비 고정 균일 배율로 확대/축소
1515
- - 모든 너비(`colwidth`)·행 높이(`rowHeight`) 동일 배율 적용 행·열 상대 비율과 표 종횡비 유지
1516
- - focus 없이 표 hover만으로 동작(코어 hover 상태 기반), 기존 행/열 개별 리사이즈와 충돌 없음
1517
- - `tableHandles` prop으로 게이트(행/열 리사이즈와 동일)
1518
-
1519
- ### v0.4.18 (2026-06-17)
1520
-
1521
- - **표 삭제 버그 수정 (병합 포함)**
1522
- - 그립 메뉴로 첫 열/행 삭제 시, 그립 클릭으로 ProseMirror 선택이 표 밖으로 빠져 삭제가 무시되던 문제 수정(삭제 대상 표를 포커스 표 기준으로 결정적으로 탐색)
1523
- - **세로 병합(rowspan) 인접 열**이 있을 때 첫 열이 삭제되지 않던 문제 수정 — 코어 prosemirror-tables가 행의 유일 셀을 지울 때 빈 셀을 남겨(`tableRow+` 스키마) `fixTables`가 원복하던 케이스. 열 삭제를 표 재구성 방식으로 변경해 colspan/rowspan 병합을 안전하게 처리
1524
- - 병합 셀이 행 축소로 collapse될 때 **행 높이를 보존**(원래 차지하던 행 높이 합을 유지)
1525
-
1526
- ### v0.4.17 (2026-06-16)
1527
-
1528
- - **표 높이(세로) 리사이즈**
1529
- - 경계 hover → 드래그로 높이 조절(가로 리사이즈와 대칭). 드래그 높이가 마우스를 따라 실시간 반영
1530
- - 높이는 셀 `rowHeight` attr로 저장·라운드트립. 행/열 추가·구조 편집 시에도 보존
1531
- - **표 블록 정렬(좌/가운데/우)**
1532
- - 상단 포매팅 툴바 + 블록 드래그핸들 메뉴에서 표 전체를 에디터 영역 기준 좌/가운데/우 정렬
1533
- - **표 하단 여백 축소**: 표 아래 불필요한 예약 공백(약 16px) 제거(핸들 여백은 유지)
1534
- - **2단 컬럼(다단) 레이아웃** *(신규)*
1535
- - 슬래시 메뉴 `/2단 컬럼`으로 좌우 2단 삽입, 단에 일반 블록 자유 배치·편집
1536
- - **블록 DnD**: 블록을 다른 블록의 좌/우 가장자리로 끌어다 놓으면 2단 컬럼 생성(노션식, 세로 드롭 인디케이터)
1537
- - 컬럼/1단 columnList 자동 정리 문서 불변식 보호
1538
- - 공식 `@blocknote/xl-multi-column`(AGPL) 대신 MIT 안전 자체 구현
1539
- - *제한(후속 예정)*: 컬럼 안↔밖 DnD/3추가, 컬럼 너비 리사이즈, 다중 블록 드래그
1540
-
1541
- ### v0.4.16 (2026-06-05)
1542
-
1543
- - **인라인 글자 크기 (Font Size)**
1544
- - 포매팅 툴바·상단 고정 툴바(FloatingMenu)에 글자 크기 드롭다운 추가 (기본 + 10~28px 프리셋 8단계)
1545
- - 커스텀 `fontSize` 스타일 스펙 등록 (`FontSize` export)
1546
- - **구버전 호환 직렬화**: 저장 JSON에는 `styles.fontSize` 대신 styled-text 형제 키 `fontSize`로 기록 — fontSize 스펙이 없는 구버전 SDK(≤0.4.15)에서도 파싱 오류 없이 로드(글자 크기만 무시)
1547
- - `liftFontSize`/`lowerFontSize` 변환 유틸 및 `SerializedStyledText` 타입 공개 export
1548
- - **`floatingMenu` 사용 팝업 포매팅 툴바 동작 개선**
1549
- - 일반 텍스트 선택 선택 팝업 툴바를 표시하지 않음 (상단 고정 툴바와 중복 + 상단 툴바에서 스타일 적용 직후 팝업이 잘못된 위치(0,0)에 재표시되던 문제 수정)
1550
- - 테이블 컨텍스트(셀 병합·세로 정렬·셀 배경)와 이미지/노드 선택(캡션·교체·다운로드 )은 팝업에만 있는 도구이므로 기존대로 팝업 표시
1551
-
1552
- ### v0.4.15 (2026-06-05)
1553
-
1554
- - **Notion 스타일 테이블 색상·정렬·포커스 핸들**
1555
- - 셀 focus 상(열)·좌()·우(셀) gutter/grip 표시, grip 클릭으로 행·열·셀 드롭다운 메뉴 (`LumirTableHandlesController`)
1556
- - 셀 배경색 지원: 셀 메뉴의 "색" 항목 및 플로팅 툴바에서 다중 셀 드래그 선택 후 일괄 적용
1557
- - 텍스트 색·배경 vs 셀 배경을 구분한 컨텍스트 라벨 색상 컨트롤
1558
- - 상단 고정 툴바(FloatingMenu)의 정렬·배경색 버튼이 테이블 셀에 올바르게 적용되도록 수정
1559
- - 행/열 grip 메뉴가 열려 범위 하이라이트 중일 때 우측 grip 미노출 처리
1560
-
1561
- ### v0.4.14 (2026-05-29)
1562
-
1563
- - **Excel/스프레드시트 붙여넣기 편집 가능한 테이블**
1564
- - Excel 복사 클립보드의 비트맵 이미지가 업로드되어 테이블이 무시되던 문제 수정
1565
- - 클립보드 HTML에 `<table>`이 있으면 이미지보다 우선 파싱하여 실제 테이블 블록 생성
1566
- - Excel 서식(셀 배경, 글자색, 정렬, 굵게/기울임/밑줄)을 BlockNote 속성으로 매핑
1567
-
1568
- ### v0.4.13 (2026-04-03)
1569
-
1570
- - **@tiptap/core 외부화(externalize)**
1571
- - 번들에 포함된 @tiptap/core 중복으로 발생하던 `proseMirrorPlugins` 런타임 에러 수정
1572
- - `@tiptap/core`를 peerDependency로 전환하고 빌드에서 external 처리
1573
-
1574
- ### v0.4.12 (2026-04-03)
1575
-
1576
- - **Numbered List & Bullet List font size 14px**
1577
- - Numbered List & Bullet List의 font size 14px일반 텍스트 크기와 통일성 있게 변경
1578
-
1579
- ### v0.4.10 (2026-03-18)
1580
-
1581
- - **동영상·이미지 업로드 진행률 표시**
1582
- - S3 업로드 진행률이 0만 보이다가 100으로 바로 완료되던 문제 개선
1583
- - `xhr.upload.onprogress`와 **보간 타이머**를 함께 사용해 중간 진행률(0→…→100)이 부드럽게 갱신되도록 변경
1584
- - 업로드 시작 직후 `onProgress(0)` 호출, 완료 시 `onProgress(100)` 보장
1585
- - README: `s3Upload.onProgress` 설명 및 업로드 진행률 표시 섹션 추가
1586
- - README: `S3UploaderConfig`에 `onProgress`, `uploadTimeoutMs`, `maxRetries` 문서화
1587
-
1588
- ### v0.4.9 (2026-03-17)
1589
-
1590
- - **업로드 용량·타임아웃 사용자 설정**
1591
- - `maxImageFileSize`, `maxVideoFileSize` prop 추가 (미설정 시 기본 10MB/100MB)
1592
- - `isImageFile(file, maxSize?)`, `isVideoFile(file, maxSize?)` 시그니처 확장
1593
- - README: 용량 및 타임아웃 설정 예시 및 `s3Upload.uploadTimeoutMs` 안내 보강
1594
-
1595
- ### v0.4.8 (2026-03)
1596
-
1597
- - README update (video & image upload)
1598
- - 버전 배포
1599
-
1600
- ### v0.4.6 (2026-03)
1601
-
1602
- - **README: S3 Presigned URL API**
1603
- - Next.js App Router용 `app/api/s3/presigned/route.ts` 구현 예시 추가 (PutObjectCommand, getSignedUrl)
1604
- - Next.js가 아닌 프로젝트: Express 예시 및 Remix/SvelteKit 등 동일 패턴 안내
1605
- - **README: 이미지·동영상 업로드 경로 분리**
1606
- - `fileNameTransform`으로 이미지/동영상 prefix 분리 (`images/`, `videos/`) 예시 및 결과 경로 설명 추가
1607
-
1608
- ### v0.4.5 (2026-03-06)
1609
-
1610
- - **README: 이미지·동영상 업로드**
1611
- - 지원 형식/용량, 삽입 경로, 설정 방법(s3Upload / uploadFile), 저장 데이터 구조, 삭제 콜백, 에러 처리 정리
1612
- - 동영상 블록은 직접 재생 가능한 파일 URL만 지원한다는 안내 추가 (YouTube/Vimeo 링크 미지원)
1613
-
1614
- ### v0.4.4 (2026-03-05)
1615
-
1616
- - **Link Preview API 핸들러 내장**
1617
- - `@lumir-company/editor/api/link-preview` 서브패스 export 추가
1618
- - 표준 Web API (`Request`/`Response`) 기반으로 Next.js, Remix, SvelteKit 호환
1619
- - 소비자는 1줄 re-export만으로 API 라우트 설정 가능 (220줄 route.ts 제거)
1620
- - `linkPreviewHandler`, `fetchUrlMetadata`, `parseMetaTags` 함수 export
1621
- - **Placeholder 문서화**
1622
- - Placeholder 사용 가이드 추가
1623
- - **README 개선**
1624
- - 링크 프리뷰 서버사이드 요구사항 설정 방법 가이드 추가
1625
- - 내장 API 핸들러 export 목록 문서화
1626
- - Props API 테이블에 `placeholder`, `linkPreview` 추가
1627
-
1628
- ### v0.4.3 (2026-02-23)
1629
-
1630
- - **링크 프리뷰 기능 추가**
1631
- - `linkPreview` prop으로 링크 미리보기 활성화 (카카오톡 스타일 OG 카드)
1632
- - URL 붙여넣기 시 자동 링크 프리뷰 블록 생성 (빈 블록이면 교체, 텍스트 있으면 하단 삽입)
1633
- - 슬래시 메뉴(`/`)에서 Link Preview 항목 추가
1634
- - 드래그 리사이즈 지원 (좌우 너비, 하단 이미지 높이 조절)
1635
- - 메타데이터에 이미지 없을 경우 이미지 영역 생략
1636
- - 에러 카드 클릭 링크 이동 지원
1637
- - `fetchLinkMetadata`, `clearMetadataCache`, `LinkMetadata` 타입 export
1638
- - **링크 툴바 커스텀**
1639
- - 텍스트 링크를 링크 프리뷰 블록으로 전환하는 버튼 추가 (`replaceBlocks` 사용)
1640
- - `linkPreview.apiEndpoint` 설정 시에만 전환 버튼 표시
1641
- - **placeholder prop 추가**
1642
- - `placeholder` prop으로 에디터 빈 블록 안내 텍스트 설정
1643
- - **이미지 삭제 기능 추가**
1644
- - `onImageDelete` 콜백 prop 추가 - 에디터에서 이미지 삭제 호출
1645
- - S3 외부 스토리지에서 이미지 자동 삭제 지원
1646
- - 지연 삭제 패턴으로 Undo/Redo 대응 가능
1647
- - 이미지 URL 추출 삭제 감지 헬퍼 함수 내장
1648
- - **보안 강화**
1649
- - URL 이스케이프 처리 추가 (XSS 방지)
1650
- - LinkButton: `javascript:`, `data:`, `vbscript:`, `file:` 프로토콜 차단
1651
- - 위험한 URL 입력 에러 메시지 표시
1652
- - **링크 삽입 버그 수정**
1653
- - 플로팅 메뉴 링크 버튼: 텍스트 미선택 시에도 URL 텍스트로 링크 삽입 지원
1654
- - `editor.focus()` 호출로 선택 상태 복원
1655
- - **README 개선**
1656
- - 링크 프리뷰 사용 가이드 API 라우트 예시 추가
1657
- - 이미지 삭제 섹션 추가 (지연 삭제 예시 포함)
1658
- - S3 삭제 API 구현 예시 추가
1659
- - Props API 문서 업데이트
1660
-
1661
- ### v0.4.2 (2026-02-23)
1662
-
1663
- - **코드 구조 리팩토링**
1664
- - FloatingMenu 컴포넌트 분리 (Icons, 개별 버튼 컴포넌트)
1665
- - 색상 상수 별도 파일로 분리 (`constants/colors.ts`)
1666
- - 미사용 기능 제거 (FontSelect, FontSizeControl)
1667
- - **에러 처리 개선**
1668
- - `LumirEditorError` 커스텀 에러 클래스 추가
1669
- - `onError` 콜백 prop 추가 - 에러 발생 시 사용자 정의 핸들링 가능
1670
- - 에러 발생 사용자 친화적 토스트 메시지 자동 표시
1671
- - **HTML 미리보기 개선**
1672
- - sandbox 설정 명확화 (JavaScript 의도적 비활성화)
1673
- - 드래그 리사이즈, 열기, 다운로드 기능 문서화
1674
- - **타입 개선**
1675
- - `LumirErrorCode`, `LumirErrorDetails` 타입 export
1676
- - `ColorItem` 타입 export
1677
-
1678
- ### v0.4.1 (2026-01-15)
1679
-
1680
- - `preserveExtension` prop 추가 - 확장자 자동 붙이기 제어 (기본: true)
1681
- - **중요**: 파일명 변환 시 확장자 위치 수정 (확장자가 항상 맨 뒤에 오도록)
1682
- - **Breaking Change**: `fileNameTransform` 파라미터 변경 - 이제 확장자 제외한 파일명만 전달됨
1683
- - 이전: `fileNameTransform: (originalName, file) => ...` → originalName에 확장자 포함
1684
- - 변경: `fileNameTransform: (nameWithoutExt, file) => ...` → nameWithoutExt에 확장자 제외
1685
- - 확장자 제거 사용 사례 문서화
1686
- - README 예제 설명 개선
1687
-
1688
- ### v0.4.0 (2026-01-15)
1689
-
1690
- - 파일명 변환 콜백 (`fileNameTransform`) 추가
1691
- - UUID 자동 추가 옵션 (`appendUUID`) 추가
1692
- - 여러 이미지 동시 업로드 시 중복 문제 해결
1693
- - 문서 대폭 개선
1694
-
1695
- ### v0.3.3 (2025-12-11)
1696
-
1697
- - 에디터 재생성 방지 최적화
1698
- - 타입 정의 개선
1
+ # LumirEditor
2
+
3
+ **이미지 전용** BlockNote 기반 Rich Text 에디터
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@lumir-company/editor.svg)](https://www.npmjs.com/package/@lumir-company/editor)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ > 이미지 업로드에 최적화된 경량 에디터. S3 업로드, 파일명 커스터마이징, 로딩 스피너 내장.
9
+
10
+ ---
11
+
12
+ ## 목차
13
+
14
+ - [특징](#특징)
15
+ - [빠른 시작](#빠른-시작)
16
+ - [이미지 업로드](#이미지-업로드)
17
+ - [S3 업로드 설정](#1-s3-업로드-권장)
18
+ - [파일명 커스터마이징](#파일명-커스터마이징)
19
+ - [커스텀 업로더](#2-커스텀-업로더)
20
+ - [동영상 업로드 및 임베딩](#동영상-업로드-및-임베딩)
21
+ - [업로드 진행률 표시](#업로드-진행률-표시-이미지동영상-공통)
22
+ - [이미지·동영상 업로드 상세 가이드](#이미지동영상-업로드-상세-가이드)
23
+ - [이미지·비디오 삭제](#이미지비디오-삭제)
24
+ - [테이블](#테이블)
25
+ - [글자 크기](#글자-크기)
26
+ - [HTML 미리보기](#html-미리보기)
27
+ - [Placeholder](#placeholder)
28
+ - [링크 프리뷰](#링크-프리뷰)
29
+ - [Props API](#props-api)
30
+ - [사용 예제](#사용-예제)
31
+ - [스타일링](#스타일링)
32
+ - [트러블슈팅](#트러블슈팅)
33
+
34
+ ---
35
+
36
+ ## 특징
37
+
38
+ | 특징 | 설명 |
39
+ | ----------------------- | -------------------------------------------------------------------------------- |
40
+ | **이미지 전용** | 이미지 업로드/드래그앤드롭 기본 지원 (비디오는 `allowVideoUpload`로 옵션 활성화) |
41
+ | **HTML 미리보기** | HTML 파일을 드래그 앤 드롭하여 iframe으로 미리보기 |
42
+ | **S3 연동** | Presigned URL 기반 S3 업로드 내장 |
43
+ | **파일명 커스터마이징** | 업로드 파일명 변경 콜백 + UUID 자동 추가 지원 |
44
+ | **로딩 스피너** | 이미지 업로드 중 자동 스피너 표시 |
45
+ | **테이블** | Notion 스타일 행·열·셀 grip 핸들, 셀 배경색, Excel 셀 붙여넣기 지원 |
46
+ | **글자 크기** | 인라인 글자 크기 변경 (프리셋 8단계 + 기본), 구버전 호환 직렬화 |
47
+ | **성능 최적화** | 애니메이션 비활성화로 빠른 렌더링 |
48
+ | **TypeScript** | 완전한 타입 안전성 |
49
+ | **테마 지원** | 라이트/다크 테마 및 커스텀 테마 |
50
+
51
+ ### 지원 이미지 형식
52
+
53
+ ```
54
+ PNG, JPEG/JPG, GIF, WebP, BMP
55
+ ```
56
+
57
+ ---
58
+
59
+ ## 빠른 시작
60
+
61
+ ### 1. 설치
62
+
63
+ ```bash
64
+ npm install @lumir-company/editor
65
+ # 또는
66
+ yarn add @lumir-company/editor
67
+ ```
68
+
69
+ **필수 Peer Dependencies:**
70
+
71
+ - `react` >= 18.0.0
72
+ - `react-dom` >= 18.0.0
73
+
74
+ ### 2. 기본 사용
75
+
76
+ ```tsx
77
+ import { LumirEditor } from "@lumir-company/editor";
78
+ import "@lumir-company/editor/style.css"; // 필수!
79
+
80
+ export default function App() {
81
+ return (
82
+ <div className="w-full h-[500px]">
83
+ <LumirEditor onContentChange={(blocks) => console.log(blocks)} />
84
+ </div>
85
+ );
86
+ }
87
+ ```
88
+
89
+ > **중요**: `style.css`를 임포트하지 않으면 에디터가 정상 작동하지 않습니다.
90
+
91
+ ### 3. Next.js에서 사용
92
+
93
+ ```tsx
94
+ "use client";
95
+
96
+ import dynamic from "next/dynamic";
97
+ import "@lumir-company/editor/style.css";
98
+
99
+ // SSR 비활성화 필수
100
+ const LumirEditor = dynamic(
101
+ () =>
102
+ import("@lumir-company/editor").then((m) => ({ default: m.LumirEditor })),
103
+ { ssr: false },
104
+ );
105
+
106
+ export default function EditorPage() {
107
+ return (
108
+ <div className="h-[500px]">
109
+ <LumirEditor />
110
+ </div>
111
+ );
112
+ }
113
+ ```
114
+
115
+ ---
116
+
117
+ ## 이미지 업로드
118
+
119
+ ### 1. S3 업로드 (권장)
120
+
121
+ Presigned URL을 사용한 안전한 S3 업로드 방식입니다.
122
+
123
+ ```tsx
124
+ <LumirEditor
125
+ s3Upload={{
126
+ apiEndpoint: "/api/s3/presigned",
127
+ env: "production",
128
+ path: "blog/images",
129
+ }}
130
+ />
131
+ ```
132
+
133
+ #### S3 파일 저장 경로
134
+
135
+ ```
136
+ {env}/{path}/{filename}
137
+
138
+ 예시:
139
+ production/blog/images/my-photo.png
140
+ ```
141
+
142
+ #### API 엔드포인트 응답 형식
143
+
144
+ 서버는 다음 형식으로 응답해야 합니다:
145
+
146
+ ```json
147
+ {
148
+ "presignedUrl": "https://s3.amazonaws.com/bucket/upload-url",
149
+ "publicUrl": "https://cdn.example.com/production/blog/images/my-photo.png"
150
+ }
151
+ ```
152
+
153
+ 클라이언트는 `apiEndpoint?key={파일키}&contentType={MIME}` 형태로 GET 요청을 보내고, 서버는 위 형식으로 JSON을 반환하면 됩니다.
154
+
155
+ #### S3 Presigned URL API 구현 예시
156
+
157
+ **Next.js (App Router)**
158
+
159
+ 파일: `app/api/s3/presigned/route.ts`
160
+
161
+ ```typescript
162
+ import { NextRequest, NextResponse } from "next/server";
163
+ import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
164
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
165
+
166
+ const s3 = new S3Client({
167
+ region: process.env.AWS_REGION!,
168
+ credentials: {
169
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
170
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
171
+ },
172
+ });
173
+
174
+ export async function GET(req: NextRequest) {
175
+ const { searchParams } = new URL(req.url);
176
+ const key = searchParams.get("key");
177
+ const contentType = searchParams.get("contentType");
178
+
179
+ if (!key) {
180
+ return NextResponse.json({ error: "key is required" }, { status: 400 });
181
+ }
182
+
183
+ const command = new PutObjectCommand({
184
+ Bucket: process.env.AWS_S3_BUCKET!,
185
+ Key: key,
186
+ ContentType: contentType || "application/octet-stream",
187
+ });
188
+
189
+ const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 60 });
190
+ const publicUrl = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
191
+
192
+ return NextResponse.json({ presignedUrl, publicUrl, key });
193
+ }
194
+ ```
195
+
196
+ 필요한 환경 변수: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET`
197
+
198
+ **Next.js가 아닌 프로젝트에서 사용하기**
199
+
200
+ 동일하게 **GET** 요청으로 `key`, `contentType` 쿼리 파라미터를 받아 `presignedUrl`, `publicUrl`을 JSON으로 반환하는 엔드포인트를 구현하면 됩니다.
201
+
202
+ - **Express (Node.js)**
203
+
204
+ ```javascript
205
+ const express = require("express");
206
+ const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
207
+ const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
208
+
209
+ const s3 = new S3Client({
210
+ region: process.env.AWS_REGION,
211
+ credentials: {
212
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
213
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
214
+ },
215
+ });
216
+
217
+ app.get("/api/s3/presigned", async (req, res) => {
218
+ const key = req.query.key;
219
+ const contentType = req.query.contentType || "application/octet-stream";
220
+
221
+ if (!key) {
222
+ return res.status(400).json({ error: "key is required" });
223
+ }
224
+
225
+ const command = new PutObjectCommand({
226
+ Bucket: process.env.AWS_S3_BUCKET,
227
+ Key: key,
228
+ ContentType: contentType,
229
+ });
230
+
231
+ const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 60 });
232
+ const publicUrl = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
233
+
234
+ res.json({ presignedUrl, publicUrl, key });
235
+ });
236
+ ```
237
+
238
+ 에디터 사용 시 `apiEndpoint`만 해당 서버 주소로 맞추면 됩니다.
239
+
240
+ ```tsx
241
+ <LumirEditor
242
+ s3Upload={{
243
+ apiEndpoint: "https://api.myapp.com/api/s3/presigned",
244
+ env: "production",
245
+ path: "uploads",
246
+ }}
247
+ />
248
+ ```
249
+
250
+ - **Remix / SvelteKit / 기타 프레임워크**
251
+ GET 라우트에서 `key`, `contentType`을 받아 `@aws-sdk/client-s3`의 `PutObjectCommand`와 `@aws-sdk/s3-request-presigner`의 `getSignedUrl`로 presigned URL을 생성한 뒤, `{ presignedUrl, publicUrl, key }` 형태로 JSON 응답하면 동일하게 사용할 수 있습니다. CORS가 필요한 경우 해당 도메인을 허용해 두세요.
252
+
253
+ ---
254
+
255
+ ### 파일명 커스터마이징
256
+
257
+ 여러 이미지를 동시에 업로드할 때 파일명 중복을 방지하고 관리하기 쉽게 만드는 기능입니다.
258
+
259
+ > **참고**: 기본적으로 확장자는 자동으로 붙습니다. `preserveExtension: false`로 설정하면 확장자를 붙이지 않습니다.
260
+
261
+ #### 옵션 1: UUID 자동 추가
262
+
263
+ ```tsx
264
+ <LumirEditor
265
+ s3Upload={{
266
+ apiEndpoint: "/api/s3/presigned",
267
+ env: "production",
268
+ path: "uploads",
269
+ appendUUID: true, // 파일명 뒤에 UUID 자동 추가
270
+ }}
271
+ />
272
+ ```
273
+
274
+ **결과:**
275
+
276
+ ```
277
+ 원본: photo.png
278
+ 업로드: photo_550e8400-e29b-41d4-a716-446655440000.png
279
+ ```
280
+
281
+ #### 옵션 2: 파일명 변환 콜백
282
+
283
+ ```tsx
284
+ <LumirEditor
285
+ s3Upload={{
286
+ apiEndpoint: "/api/s3/presigned",
287
+ env: "production",
288
+ path: "uploads",
289
+ fileNameTransform: (nameWithoutExt, file) => {
290
+ // nameWithoutExt는 확장자가 제거된 파일명 (예: "photo")
291
+ // 확장자는 자동으로 붙습니다
292
+ const userId = getCurrentUserId();
293
+ return `${userId}_${nameWithoutExt}`;
294
+ },
295
+ }}
296
+ />
297
+ ```
298
+
299
+ **결과:**
300
+
301
+ ```
302
+ 원본: photo.png
303
+ → nameWithoutExt: "photo"
304
+ → 변환 후: "user123_photo"
305
+ → 최종: user123_photo.png
306
+ ```
307
+
308
+ #### 옵션 3: 조합 사용 (권장)
309
+
310
+ ```tsx
311
+ <LumirEditor
312
+ s3Upload={{
313
+ apiEndpoint: "/api/s3/presigned",
314
+ env: "production",
315
+ path: "uploads",
316
+ fileNameTransform: (nameWithoutExt) => `user123_${nameWithoutExt}`,
317
+ appendUUID: true, // 변환 후 UUID 추가
318
+ }}
319
+ />
320
+ ```
321
+
322
+ **결과:**
323
+
324
+ ```
325
+ 원본: photo.png
326
+ → nameWithoutExt: "photo"
327
+ 1. fileNameTransform 적용: "user123_photo"
328
+ 2. appendUUID 적용: "user123_photo_550e8400-e29b-41d4"
329
+ 3. 확장자 붙이기: user123_photo_550e8400-e29b-41d4.png
330
+ ```
331
+
332
+ #### 실전 예제: 타임스탬프 + UUID
333
+
334
+ ```tsx
335
+ function MyEditor() {
336
+ return (
337
+ <LumirEditor
338
+ s3Upload={{
339
+ apiEndpoint: "/api/s3/presigned",
340
+ env: "production",
341
+ path: "uploads",
342
+ fileNameTransform: (nameWithoutExt, file) => {
343
+ // nameWithoutExt는 이미 확장자가 제거됨
344
+ const timestamp = new Date().toISOString().split("T")[0]; // 2024-01-15
345
+ return `${timestamp}_${nameWithoutExt}`;
346
+ },
347
+ appendUUID: true,
348
+ }}
349
+ />
350
+ );
351
+ }
352
+ ```
353
+
354
+ **결과:**
355
+
356
+ ```
357
+ 원본: photo.png
358
+ → nameWithoutExt: "photo"
359
+ 1. fileNameTransform: "2024-01-15_photo"
360
+ 2. appendUUID: "2024-01-15_photo_550e8400-e29b-41d4"
361
+ 3. 확장자 붙이기: 2024-01-15_photo_550e8400-e29b-41d4.png
362
+ ```
363
+
364
+ #### 옵션 4: 확장자 제거 (preserveExtension: false)
365
+
366
+ ```tsx
367
+ <LumirEditor
368
+ s3Upload={{
369
+ apiEndpoint: "/api/s3/presigned",
370
+ env: "production",
371
+ path: "uploads",
372
+ fileNameTransform: (nameWithoutExt) => `${nameWithoutExt}_custom`,
373
+ preserveExtension: false, // 확장자 안 붙임
374
+ }}
375
+ />
376
+ ```
377
+
378
+ **결과:**
379
+
380
+ ```
381
+ 원본: photo.png
382
+ → nameWithoutExt: "photo"
383
+ → 변환 후: "photo_custom"
384
+ → 최종: photo_custom (확장자 없음)
385
+ ```
386
+
387
+ **사용 사례**: WebP 변환 등 서버에서 확장자를 변경하는 경우
388
+
389
+ ```tsx
390
+ fileNameTransform: (nameWithoutExt) => `${nameWithoutExt}.webp`,
391
+ preserveExtension: false,
392
+ ```
393
+
394
+ ---
395
+
396
+ ### 2. 커스텀 업로더
397
+
398
+ 자체 업로드 로직을 사용할 때:
399
+
400
+ ```tsx
401
+ <LumirEditor
402
+ uploadFile={async (file) => {
403
+ const formData = new FormData();
404
+ formData.append("image", file);
405
+
406
+ const response = await fetch("/api/upload", {
407
+ method: "POST",
408
+ body: formData,
409
+ });
410
+
411
+ const { url } = await response.json();
412
+ return url; // 업로드된 이미지 URL 반환
413
+ }}
414
+ />
415
+ ```
416
+
417
+ ### 3. 헬퍼 함수 사용
418
+
419
+ ```tsx
420
+ import { createS3Uploader } from "@lumir-company/editor";
421
+
422
+ const s3Uploader = createS3Uploader({
423
+ apiEndpoint: "/api/s3/presigned",
424
+ env: "production",
425
+ path: "images",
426
+ appendUUID: true,
427
+ });
428
+
429
+ // 에디터에 적용
430
+ <LumirEditor uploadFile={s3Uploader} />;
431
+
432
+ // 또는 별도로 사용
433
+ const imageUrl = await s3Uploader(imageFile);
434
+ ```
435
+
436
+ ### 업로드 우선순위
437
+
438
+ 1. `uploadFile` prop이 있으면 우선 사용
439
+ 2. `uploadFile` 없고 `s3Upload`가 있으면 S3 업로드 사용
440
+ 3. 둘 다 없으면 업로드 실패
441
+
442
+ ---
443
+
444
+ ## 동영상 업로드 및 임베딩
445
+
446
+ `allowVideoUpload={true}`로 설정하면 동영상 업로드와 에디터 내 재생이 가능합니다. S3/`uploadFile`은 이미지와 동일한 설정을 사용하며, 동영상은 최대 100MB까지 허용됩니다.
447
+
448
+ ### 동영상 업로드 활성화
449
+
450
+ ```tsx
451
+ <LumirEditor
452
+ allowVideoUpload={true}
453
+ s3Upload={{
454
+ apiEndpoint: "/api/s3/presigned",
455
+ env: "production",
456
+ path: "videos",
457
+ appendUUID: true,
458
+ }}
459
+ />
460
+ ```
461
+
462
+ - **지원 형식**: MP4, WebM, OGG
463
+ - **삽입 경로**: 붙여넣기, 드래그 앤 드롭, 슬래시 메뉴("Video"), FloatingMenu 이미지/동영상 버튼
464
+
465
+ ### 업로드 진행률 표시 (이미지·동영상 공통)
466
+
467
+ S3 업로드 시 `s3Upload.onProgress` 콜백을 지정하면 업로드 진행률(0~100%)을 받을 수 있습니다. 에디터는 내부적으로 이 값을 사용해 업로드 중 툴바에 **"n%"** 를 표시합니다. 동영상처럼 대용량 파일은 브라우저가 `progress` 이벤트를 자주 보내지 않을 수 있어, 내부적으로 **보간 로직**을 적용해 0→100만 보이지 않고 중간 진행률이 부드럽게 갱신되도록 했습니다.
468
+
469
+ ```tsx
470
+ <LumirEditor
471
+ allowVideoUpload={true}
472
+ s3Upload={{
473
+ apiEndpoint: "/api/s3/presigned",
474
+ env: "production",
475
+ path: "videos",
476
+ appendUUID: true,
477
+ onProgress: (percent) => {
478
+ console.log(`업로드 진행률: ${percent}%`);
479
+ // 에디터 기본 UI에 이미 표시되며, 필요 시 자체 프로그레스 바 등에 연동 가능
480
+ },
481
+ }}
482
+ />
483
+ ```
484
+
485
+ - **동작**: S3 PUT 요청 시에만 호출됩니다. Presigned URL 요청 단계에서는 호출되지 않습니다.
486
+ - **표시**: `onProgress`를 넘기면 에디터 툴바에 `n%`가 자동 표시되며, 업로드 완료 시 숨겨집니다.
487
+
488
+ ### 데이터 내부 동영상 임베딩
489
+
490
+ 동영상 블록은 `initialContent` / `onContentChange`에 포함됩니다. 저장 시 `{ type: "video", props: { url: "..." } }` 형태로 블록이 유지됩니다.
491
+
492
+ - **재생**: 화면에서 동영상을 재생하려면 `allowVideoUpload={true}`로 두어야 합니다. 이렇게 해야 video 확장이 활성화되어 BlockNote 기본 플레이어가 렌더링됩니다.
493
+ - `allowVideoUpload={false}`인 상태에서 initialContent에 video 블록만 넣으면 데이터는 보존되지만, 재생 UI는 비활성화된 확장 때문에 표시되지 않을 수 있습니다.
494
+ - **지원 URL**: 비디오 블록의 `url`은 **직접 재생 가능한 비디오 파일 URL**만 지원합니다(예: S3에 업로드된 `.mp4`, `.webm`, `.ogg`). YouTube·Vimeo 등 스트리밍 페이지 URL(`youtube.com/watch?v=...` 등)은 `<video>` 요소의 `src`로 재생되지 않으므로, 해당 링크를 video 블록 URL로 넣으면 재생되지 않습니다. YouTube 임베드가 필요하면 별도 embed 블록 또는 iframe 삽입 방식을 고려해야 합니다.
495
+
496
+ ---
497
+
498
+ ## 이미지·동영상 업로드 상세 가이드
499
+
500
+ 이미지와 동영상 업로드 기능을 함께 쓰는 방법을 단계별로 정리했습니다.
501
+
502
+ ### 1. 개요
503
+
504
+ | 구분 | 이미지 | 동영상 |
505
+ | --------------- | ------------------------------- | ------------------------------------------ |
506
+ | **기본 동작** | 업로드 항상 사용 가능 (설정 시) | `allowVideoUpload={true}`일 때만 사용 가능 |
507
+ | **최대 용량** | 10MB | 100MB |
508
+ | **업로드 설정** | `s3Upload` 또는 `uploadFile` | 이미지와 동일한 설정 공유 |
509
+
510
+ - 이미지만 쓸 때: `s3Upload` 또는 `uploadFile`만 설정하면 됩니다.
511
+ - 이미지 + 동영상: 위 설정에 더해 `allowVideoUpload={true}`를 넣습니다.
512
+
513
+ ### 2. 지원 형식 및 제한
514
+
515
+ **이미지**
516
+
517
+ - **MIME**: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/bmp`
518
+ - **확장자**: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp`
519
+ - **용량**: 기본 최대 10MB. `maxImageFileSize`(바이트)로 변경 가능.
520
+ - **제외**: SVG (XSS 방지로 업로드 불가)
521
+
522
+ **동영상** (`allowVideoUpload={true}`일 때)
523
+
524
+ - **MIME**: `video/mp4`, `video/webm`, `video/ogg`, `video/quicktime`
525
+ - **확장자**: `.mp4`, `.webm`, `.ogg`, `.mov`
526
+ - **용량**: 기본 최대 100MB. `maxVideoFileSize`(바이트)로 변경 가능.
527
+
528
+ **업로드 타임아웃**: S3 업로드 시 PUT 요청 타임아웃은 `s3Upload.uploadTimeoutMs`로 설정합니다. 미설정 시 120초(120000ms)가 적용됩니다.
529
+
530
+ **용량 및 타임아웃 사용자 설정**
531
+
532
+ 이미지·동영상 최대 용량과 S3 PUT 타임아웃을 props로 변경할 수 있습니다. 미설정 시 기본값(이미지 10MB, 동영상 100MB, 타임아웃 120초)이 적용됩니다.
533
+
534
+ ```tsx
535
+ <LumirEditor
536
+ allowVideoUpload={true}
537
+ maxImageFileSize={5 * 1024 * 1024} // 5MB
538
+ maxVideoFileSize={200 * 1024 * 1024} // 200MB
539
+ s3Upload={{
540
+ apiEndpoint: "/api/s3/presigned",
541
+ env: "production",
542
+ path: "uploads",
543
+ uploadTimeoutMs: 180000, // 180초 (대용량 동영상 시 연장)
544
+ }}
545
+ />
546
+ ```
547
+
548
+ ### 3. 삽입 경로 (공통)
549
+
550
+ 이미지·동영상 모두 아래 경로로 삽입할 수 있습니다.
551
+
552
+ | 경로 | 설명 |
553
+ | ------------------ | ----------------------------------------------------------------- |
554
+ | **붙여넣기** | 클립보드 이미지/동영상 → Ctrl+V (또는 Cmd+V) |
555
+ | **드래그 앤 드롭** | 파일을 에디터 영역으로 끌어다 놓기 |
556
+ | **슬래시 메뉴** | `/` 입력 후 "Image" 또는 "Video"(동영상 허용 시) 선택 → 파일 선택 |
557
+ | **FloatingMenu** | 툴바의 이미지/동영상 버튼 클릭 → 파일 선택 |
558
+
559
+ 동영상은 `allowVideoUpload={true}`일 때만 슬래시 메뉴에 "Video"가 보이고, FloatingMenu에서도 동영상 파일을 선택할 수 있습니다.
560
+
561
+ ### 4. 설정 방법 요약
562
+
563
+ **우선순위**
564
+
565
+ 1. `uploadFile` prop이 있으면 → 해당 함수로 업로드 (이미지·동영상 동일)
566
+ 2. `uploadFile` 없고 `s3Upload`가 있으면 → Presigned URL 기반 S3 업로드
567
+ 3. 둘 다 없으면 → 업로드 시 에러 (에디터는 동작하지만 파일 삽입 불가)
568
+
569
+ **이미지만 사용하는 경우**
570
+
571
+ ```tsx
572
+ <LumirEditor
573
+ s3Upload={{
574
+ apiEndpoint: "/api/s3/presigned",
575
+ env: "production",
576
+ path: "images",
577
+ appendUUID: true,
578
+ }}
579
+ onContentChange={(blocks) => setContent(blocks)}
580
+ />
581
+ ```
582
+
583
+ **이미지 + 동영상 사용하는 경우**
584
+
585
+ ```tsx
586
+ <LumirEditor
587
+ allowVideoUpload={true}
588
+ s3Upload={{
589
+ apiEndpoint: "/api/s3/presigned",
590
+ env: "production",
591
+ path: "images",
592
+ appendUUID: true,
593
+ }}
594
+ onContentChange={(blocks) => setContent(blocks)}
595
+ />
596
+ ```
597
+
598
+ 동영상은 같은 `s3Upload`로 업로드됩니다. 서버에서 이미지와 동영상을 다른 경로에 두고 싶다면 아래처럼 `fileNameTransform`으로 prefix를 분리하면 됩니다.
599
+
600
+ **이미지·동영상 업로드 경로 분리 (fileNameTransform)**
601
+
602
+ `fileNameTransform`의 두 번째 인자 `file`로 이미지/동영상을 구분해, 파일명 앞에 폴더 prefix를 붙이면 됩니다. 최종 S3 키는 `{env}/{path}/{filename}` 이므로, `filename`에 `images/...` / `videos/...` 를 넣으면 경로가 나뉩니다.
603
+
604
+ ```tsx
605
+ <LumirEditor
606
+ allowVideoUpload={true}
607
+ s3Upload={{
608
+ apiEndpoint: "/api/s3/presigned",
609
+ env: "production",
610
+ path: "uploads", // 공통 상위 경로
611
+ appendUUID: true,
612
+ fileNameTransform: (nameWithoutExt, file) => {
613
+ const isVideo = file.type.startsWith("video/");
614
+ return `${isVideo ? "videos" : "images"}/${nameWithoutExt}`;
615
+ },
616
+ }}
617
+ />
618
+ ```
619
+
620
+ 결과 예: 이미지 → `production/uploads/images/photo_abc123.png`, 동영상 → `production/uploads/videos/clip_def456.mp4`
621
+
622
+ **커스텀 업로더로 이미지·동영상 통합**
623
+
624
+ ```tsx
625
+ <LumirEditor
626
+ allowVideoUpload={true}
627
+ uploadFile={async (file) => {
628
+ const formData = new FormData();
629
+ formData.append("file", file);
630
+ const res = await fetch("/api/upload", { method: "POST", body: formData });
631
+ const { url } = await res.json();
632
+ return url;
633
+ }}
634
+ />
635
+ ```
636
+
637
+ `uploadFile`은 이미지/동영상 구분 없이 `File`을 받아 업로드 후 **공개 URL 문자열**을 반환하면 됩니다.
638
+
639
+ ### 5. 저장 데이터 구조
640
+
641
+ `onContentChange` / `initialContent`에서 사용하는 블록 형태입니다.
642
+
643
+ **이미지 블록**
644
+
645
+ ```json
646
+ {
647
+ "type": "image",
648
+ "props": {
649
+ "url": "https://your-cdn.com/images/photo_xxx.png",
650
+ "caption": "",
651
+ "previewWidth": 512
652
+ },
653
+ "content": [],
654
+ "children": []
655
+ }
656
+ ```
657
+
658
+ **동영상 블록**
659
+
660
+ ```json
661
+ {
662
+ "type": "video",
663
+ "props": {
664
+ "url": "https://your-cdn.com/videos/clip_xxx.mp4"
665
+ },
666
+ "content": [],
667
+ "children": []
668
+ }
669
+ ```
670
+
671
+ `url`은 반드시 **브라우저에서 직접 재생 가능한 URL**이어야 합니다. 동영상은 YouTube/Vimeo 링크가 아니라, 업로드 후 받은 `.mp4` 등 직접 재생 URL만 지원합니다.
672
+
673
+ ### 6. 삭제 시 콜백
674
+
675
+ 이미지·동영상 블록을 에디터에서 삭제하면 `onImageDelete`가 호출됩니다. 인자는 삭제된 미디어의 URL입니다.
676
+
677
+ ```tsx
678
+ <LumirEditor
679
+ s3Upload={{ ... }}
680
+ allowVideoUpload={true}
681
+ onImageDelete={(url) => {
682
+ // url은 이미지 또는 동영상 URL
683
+ console.log("삭제됨:", url);
684
+ // S3/스토리지에서 삭제 API 호출
685
+ }}
686
+ />
687
+ ```
688
+
689
+ Undo로 블록을 복원해도 이미 삭제 API를 호출했다면 서버 상태와 불일치할 수 있으므로, 지연 삭제(예: 5분 후 삭제) 패턴을 권장합니다. 자세한 예시는 [이미지·비디오 삭제](#이미지비디오-삭제)를 참고하세요.
690
+
691
+ ### 7. 에러 처리
692
+
693
+ - **지원하지 않는 형식**: 업로드 시 `LumirEditorError`가 발생할 수 있으며, `onError`로 처리할 수 있습니다.
694
+ - **용량 초과**: 기본 한도(이미지 10MB·동영상 100MB)를 넘으면 업로드가 거부됩니다. `maxImageFileSize`, `maxVideoFileSize`로 한도를 변경할 수 있습니다.
695
+ - **업로드 실패**: `uploadFile` 또는 S3 업로드에서 예외가 나면 해당 파일만 삽입되지 않고, 콘솔에 경고가 출력됩니다.
696
+
697
+ ```tsx
698
+ <LumirEditor
699
+ s3Upload={{ ... }}
700
+ onError={(err) => {
701
+ console.error("에디터 에러:", err);
702
+ // 토스트 등으로 사용자 알림
703
+ }}
704
+ />
705
+ ```
706
+
707
+ ---
708
+
709
+ ## 이미지·비디오 삭제
710
+
711
+ 에디터에서 **이미지 또는 비디오**가 삭제될 때 S3 등 외부 스토리지에서도 자동으로 삭제하고 싶다면 `onImageDelete` 콜백을 사용하세요. 이미지 블록과 비디오 블록 삭제 시 모두 호출됩니다.
712
+
713
+ ### 기본 사용
714
+
715
+ ```tsx
716
+ <LumirEditor
717
+ s3Upload={{
718
+ apiEndpoint: "/api/s3/presigned",
719
+ env: "production",
720
+ path: "images",
721
+ }}
722
+ onImageDelete={(imageUrl) => {
723
+ console.log("미디어(이미지/비디오) 삭제됨:", imageUrl);
724
+ // S3에서 삭제 로직 구현
725
+ }}
726
+ />
727
+ ```
728
+
729
+ ### 권장: 지연 삭제 (Undo/Redo 대응)
730
+
731
+ Undo로 이미지·비디오를 복원할 수 있도록 **지연 삭제**를 권장합니다.
732
+
733
+ ```tsx
734
+ "use client";
735
+
736
+ import { useState, useRef, useCallback } from "react";
737
+
738
+ function Editor() {
739
+ const pendingDeletes = useRef(new Map());
740
+
741
+ const handleImageDelete = useCallback((imageUrl: string) => {
742
+ // 이미 예약된 삭제가 있으면 무시
743
+ if (pendingDeletes.current.has(imageUrl)) return;
744
+
745
+ // 5분 후 삭제 예약
746
+ const timeoutId = setTimeout(
747
+ async () => {
748
+ pendingDeletes.current.delete(imageUrl);
749
+
750
+ // S3에서 실제 삭제
751
+ await fetch(`/api/s3/delete?url=${encodeURIComponent(imageUrl)}`, {
752
+ method: "DELETE",
753
+ });
754
+ },
755
+ 5 * 60 * 1000,
756
+ ); // 5분
757
+
758
+ pendingDeletes.current.set(imageUrl, timeoutId);
759
+ }, []);
760
+
761
+ return (
762
+ <LumirEditor
763
+ s3Upload={
764
+ {
765
+ /* ... */
766
+ }
767
+ }
768
+ onImageDelete={handleImageDelete}
769
+ />
770
+ );
771
+ }
772
+ ```
773
+
774
+ ### S3 삭제 API 예시
775
+
776
+ > **참고**: `onImageDelete`는 **프레임워크 독립적**이며, 이미지와 비디오 삭제 시 모두 호출됩니다. 아래는 각 환경별 구현 예시입니다.
777
+
778
+ #### Next.js API Route
779
+
780
+ **파일**: `app/api/s3/delete/route.ts`
781
+
782
+ ```typescript
783
+ import { NextRequest, NextResponse } from "next/server";
784
+ import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";
785
+
786
+ const s3 = new S3Client({
787
+ region: process.env.AWS_REGION!,
788
+ credentials: {
789
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
790
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
791
+ },
792
+ });
793
+
794
+ export async function DELETE(req: NextRequest) {
795
+ const { searchParams } = new URL(req.url);
796
+ const imageUrl = searchParams.get("url");
797
+
798
+ if (!imageUrl) {
799
+ return NextResponse.json({ error: "url is required" }, { status: 400 });
800
+ }
801
+
802
+ // URL에서 S3 키 추출
803
+ const key = extractKeyFromUrl(imageUrl);
804
+
805
+ await s3.send(
806
+ new DeleteObjectCommand({
807
+ Bucket: process.env.AWS_S3_BUCKET!,
808
+ Key: key,
809
+ }),
810
+ );
811
+
812
+ return NextResponse.json({ success: true });
813
+ }
814
+
815
+ function extractKeyFromUrl(url: string): string {
816
+ const urlObj = new URL(url);
817
+ return decodeURIComponent(urlObj.pathname.slice(1));
818
+ }
819
+ ```
820
+
821
+ **클라이언트 구현**:
822
+
823
+ ```tsx
824
+ const handleImageDelete = (imageUrl: string) => {
825
+ fetch(`/api/s3/delete?url=${encodeURIComponent(imageUrl)}`, {
826
+ method: "DELETE",
827
+ });
828
+ };
829
+
830
+ <LumirEditor onImageDelete={handleImageDelete} />;
831
+ ```
832
+
833
+ #### React + Express
834
+
835
+ **서버** (`server.js`):
836
+
837
+ ```javascript
838
+ app.delete("/api/images", async (req, res) => {
839
+ const { imageUrl } = req.body;
840
+ const key = extractKeyFromS3Url(imageUrl);
841
+
842
+ await s3Client.send(
843
+ new DeleteObjectCommand({
844
+ Bucket: process.env.S3_BUCKET,
845
+ Key: key,
846
+ }),
847
+ );
848
+
849
+ res.json({ success: true });
850
+ });
851
+ ```
852
+
853
+ **클라이언트**:
854
+
855
+ ```tsx
856
+ const handleImageDelete = async (imageUrl: string) => {
857
+ await fetch("https://api.myapp.com/api/images", {
858
+ method: "DELETE",
859
+ headers: { "Content-Type": "application/json" },
860
+ body: JSON.stringify({ imageUrl }),
861
+ });
862
+ };
863
+
864
+ <LumirEditor onImageDelete={handleImageDelete} />;
865
+ ```
866
+
867
+ #### React Native + Firebase Storage
868
+
869
+ ```tsx
870
+ import storage from "@react-native-firebase/storage";
871
+
872
+ const handleImageDelete = async (imageUrl: string) => {
873
+ const ref = storage().refFromURL(imageUrl);
874
+ await ref.delete();
875
+ };
876
+
877
+ <LumirEditor onImageDelete={handleImageDelete} />;
878
+ ```
879
+
880
+ #### Vue + Axios + FastAPI
881
+
882
+ ```typescript
883
+ const handleImageDelete = async (imageUrl: string) => {
884
+ await axios.delete("https://api.myapp.com/v1/images", {
885
+ data: { imageUrl },
886
+ });
887
+ };
888
+ ```
889
+
890
+ ### 주의사항
891
+
892
+ | 항목 | 설명 |
893
+ | --------------- | --------------------------------------------- |
894
+ | **Undo/Redo** | 지연 삭제로 복원 가능하게 구현 (권장: 5-10분) |
895
+ | **권한 검증** | 프로덕션에서는 인증/인가 필수 |
896
+ | **참조 카운트** | 같은 이미지를 여러 문서에서 사용하는지 확인 |
897
+ | **삭제 로그** | 감사 추적을 위한 삭제 기록 저장 권장 |
898
+
899
+ ---
900
+
901
+ ## 테이블
902
+
903
+ 슬래시 메뉴(`/`)에서 "Table"을 선택하거나 Excel/스프레드시트 셀을 붙여넣어 테이블을 만들 수 있습니다.
904
+
905
+ ### Notion 스타일 grip 핸들
906
+
907
+ 셀에 포커스하면 셀 주변에 핸들(grip)이 표시됩니다.
908
+
909
+ | 위치 | 동작 |
910
+ | ------------- | --------------------------------------------------------------------- |
911
+ | **상단 grip** | 클릭 → 열 메뉴 (열 삭제, 왼쪽/오른쪽에 열 추가, 색) / 드래그 → 열 이동 |
912
+ | **좌측 grip** | 클릭 → 행 메뉴 (행 삭제, 위/아래에 행 추가, 색) / 드래그 → 행 이동 |
913
+ | **우측 grip** | hover → 셀 메뉴 (셀 배경색 등) |
914
+
915
+ - 행/열 메뉴가 열리면 해당 행/열 전체가 하이라이트됩니다
916
+ - 표 우측/하단 가장자리 hover 시 행/열 추가 버튼이 표시됩니다
917
+
918
+ ### 셀 배경색·정렬
919
+
920
+ - **단일 셀**: 우측 grip 또는 행/열 메뉴의 "색" 항목에서 셀 배경색 적용
921
+ - **다중 셀**: 셀을 드래그로 범위 선택한 뒤 플로팅 툴바의 색상 버튼으로 일괄 적용
922
+ - **정렬**: 셀 선택 후 툴바의 정렬 버튼으로 셀 단위 텍스트 정렬 적용
923
+
924
+ ### Excel/스프레드시트 붙여넣기
925
+
926
+ Excel 등에서 복사한 셀 범위를 붙여넣으면(`Ctrl+V`) 이미지가 아닌 **편집 가능한 테이블**로 삽입됩니다. 셀 배경색, 글자색, 정렬, 굵게/기울임/밑줄 서식이 함께 변환됩니다.
927
+
928
+ ### 테이블 기능 설정 (`tables` prop)
929
+
930
+ ```tsx
931
+ <LumirEditor
932
+ tables={{
933
+ splitCells: true, // 셀 병합/분할 (기본: true)
934
+ cellBackgroundColor: true, // 셀 배경색 (기본: true)
935
+ cellTextColor: true, // 셀 글자색 (기본: true)
936
+ headers: true, // 헤더 행/열 (기본: true)
937
+ }}
938
+ />
939
+ ```
940
+
941
+ 테이블 핸들 UI 전체를 끄려면 `tableHandles={false}`를 사용합니다.
942
+
943
+ ---
944
+
945
+ ## 글자 크기
946
+
947
+ 텍스트를 선택한 뒤 **포매팅 툴바** 또는 **상단 고정 툴바(FloatingMenu)** 의 글자 크기 드롭다운으로 인라인 글자 크기를 변경할 수 있습니다.
948
+
949
+ - 프리셋: **기본**(14px, 스타일 제거) / 10 / 12 / 14 / 16 / 18 / 20 / 24 / 28 (px)
950
+ - 테이블 셀 내부 텍스트에도 동일하게 적용됩니다 (인라인 스타일)
951
+ - 외부 HTML(웹페이지·Excel 등)을 붙여넣을 때의 글자 크기는 가져오지 않습니다
952
+
953
+ ### 하위호환 직렬화 포맷 (중요)
954
+
955
+ 글자 크기는 저장 JSON에서 `styles` 맵이 아닌 **styled-text의 형제(sibling) 키 `fontSize`** 로 직렬화됩니다:
956
+
957
+ ```json
958
+ {
959
+ "type": "paragraph",
960
+ "content": [
961
+ { "type": "text", "text": "큰 글씨", "styles": { "bold": true }, "fontSize": "18px" }
962
+ ]
963
+ }
964
+ ```
965
+
966
+ 이유: BlockNote는 `styles` 맵에 스키마에 없는 스타일 키가 있으면 예외를 던지므로,
967
+ `styles.fontSize`로 저장하면 **fontSize 스펙이 없는 구버전 SDK(≤0.4.15)가 해당 JSON을
968
+ `initialContent`로 로드할 때 에디터가 크래시**합니다. 형제 키 방식은 구버전에서
969
+ 조용히 무시되어(글자 크기만 미표시) 안전하게 로드됩니다.
970
+
971
+ - 에디터 로드/저장 시 변환은 자동입니다 (`initialContent` ↔ `onContentChange`)
972
+ - BlockNote 외부 렌더러에서 저장 JSON을 직접 렌더링한다면, 공개 export된
973
+ `liftFontSize(blocks)`로 형제 키를 `styles.fontSize`로 복원한 뒤 사용하세요
974
+ - 직렬화 형태 타입은 `SerializedStyledText`로 export됩니다
975
+
976
+ > ⚠️ 에디터 블록 JSON을 외부로 내보내는 새 경로를 추가할 경우 반드시
977
+ > `lowerFontSize`를 거쳐야 합니다. `styles.fontSize`가 저장 JSON에 유출되면
978
+ > 구버전 소비 앱이 크래시합니다.
979
+
980
+ ---
981
+
982
+ ## HTML 미리보기
983
+
984
+ LumirEditor는 HTML 파일을 iframe을 사용하여 미리보기할 수 있는 커스텀 블록을 제공합니다. 편집 불가능한 순수 미리보기 기능으로, HTML 문서를 안전하게 표시할 수 있습니다.
985
+
986
+ ### 사용 방법
987
+
988
+ #### 1. 드래그 앤 드롭
989
+
990
+ HTML 파일(`.html`, `.htm`)을 에디터에 드래그 앤 드롭하면 자동으로 iframe 미리보기 블록이 삽입됩니다.
991
+
992
+ ```tsx
993
+ <LumirEditor />
994
+ ```
995
+
996
+ - **지원 파일 형식**: `.html`, `.htm`
997
+ - **특징**:
998
+ - 편집 불가능한 순수 미리보기
999
+ - 접기/펼치기 기능
1000
+ - 안전한 sandbox 처리 (`allow-scripts`·`allow-same-origin` 미허용 → JavaScript 실행 및 부모 페이지 접근 차단)
1001
+ - 파일명 표시
1002
+
1003
+ #### 2. 슬래시 메뉴
1004
+
1005
+ 에디터에서 `/`를 입력하고 "HTML Preview"를 선택하면 예제 HTML 미리보기 블록이 삽입됩니다.
1006
+
1007
+ ```
1008
+ / → HTML Preview
1009
+ ```
1010
+
1011
+ ### 특징
1012
+
1013
+ - **iframe 기반**: HTML 문서를 독립된 iframe에서 안전하게 렌더링
1014
+ - **Sandbox 보안**: `sandbox="allow-popups allow-forms"` — `allow-scripts`와 `allow-same-origin`을 의도적으로 제외하여 JavaScript 실행과 부모 페이지 접근을 차단
1015
+ - **접기/펼치기**: 헤더 클릭으로 미리보기 영역 토글
1016
+ - **드래그 리사이즈**: 하단 핸들을 드래그하여 높이 조절 가능 (100px ~ 1200px)
1017
+ - **새 창 열기**: HTML 문서를 새 창에서 전체 화면으로 확인
1018
+ - **다운로드**: HTML 파일로 다운로드
1019
+ - **편집 불가**: 순수 미리보기 전용
1020
+
1021
+ ### 사용 예제
1022
+
1023
+ ```tsx
1024
+ import { LumirEditor } from "@lumir-company/editor";
1025
+ import "@lumir-company/editor/style.css";
1026
+
1027
+ function App() {
1028
+ return (
1029
+ <div className="w-full h-[600px]">
1030
+ <LumirEditor
1031
+ onContentChange={(blocks) => {
1032
+ // HTML 미리보기 블록도 일반 블록과 동일하게 처리됨
1033
+ console.log(blocks);
1034
+ }}
1035
+ />
1036
+ </div>
1037
+ );
1038
+ }
1039
+ ```
1040
+
1041
+ ### 프로그래밍 방식으로 블록 삽입
1042
+
1043
+ ```tsx
1044
+ import { HtmlPreviewBlock } from "@lumir-company/editor";
1045
+
1046
+ // 에디터 인스턴스에서 직접 블록 삽입
1047
+ editor.insertBlocks([
1048
+ {
1049
+ type: "htmlPreview",
1050
+ props: {
1051
+ htmlContent: "<h1>Hello World</h1><p>This is HTML content</p>",
1052
+ fileName: "example.html",
1053
+ height: "400px",
1054
+ },
1055
+ },
1056
+ ]);
1057
+ ```
1058
+
1059
+ ### 주의사항
1060
+
1061
+ - HTML 내용은 iframe의 `sandbox="allow-popups allow-forms"` 속성으로 보안이 강화되어 있습니다 (`allow-scripts`·`allow-same-origin` 미허용)
1062
+ - **JavaScript는 의도적으로 비활성화**되어 있습니다 (보안상 이유)
1063
+ - 외부 리소스(CSS, JS, 이미지 등)는 상대 경로가 작동하지 않을 수 있습니다
1064
+ - 인라인 CSS 스타일을 권장합니다
1065
+
1066
+ ---
1067
+
1068
+ ## Placeholder
1069
+
1070
+ 에디터가 비어있을 때 사용자에게 안내 텍스트를 표시합니다.
1071
+
1072
+ ### 사용 방법
1073
+
1074
+ ```tsx
1075
+ <LumirEditor placeholder="내용을 입력하세요..." />
1076
+ ```
1077
+
1078
+ - 빈 블록에 연한 색상으로 안내 텍스트가 표시됩니다
1079
+ - 사용자가 입력을 시작하면 자동으로 사라집니다
1080
+ - 모든 빈 블록(첫 번째 블록 포함)에 동일한 텍스트가 적용됩니다
1081
+
1082
+ ---
1083
+
1084
+ ## 링크 프리뷰
1085
+
1086
+ URL을 붙여넣거나 슬래시 메뉴에서 선택하면 Open Graph 카드를 표시합니다.
1087
+
1088
+ > **서버 사이드 필수**: 링크 프리뷰는 외부 사이트의 OG 메타데이터를 가져오기 위해 **서버 사이드 API 라우트**가 필요합니다. 브라우저의 CORS 정책으로 인해 클라이언트에서 직접 외부 HTML을 가져올 수 없습니다.
1089
+
1090
+ ### 사용 조건
1091
+
1092
+ | 조건 | 설명 |
1093
+ | -------------------------- | -------------------------------------------------------------------------- |
1094
+ | **서버 환경** | Next.js, Remix, SvelteKit 등 서버 사이드 라우팅을 지원하는 프레임워크 필요 |
1095
+ | **API 라우트** | 패키지 내장 핸들러를 re-export하는 1줄짜리 파일 필요 |
1096
+ | **순수 React (CRA, Vite)** | 별도 백엔드 서버 없이는 사용 불가 |
1097
+
1098
+ ### 설정 방법 (Next.js App Router)
1099
+
1100
+ **1단계: API 라우트 생성** (1줄)
1101
+
1102
+ ```ts
1103
+ // src/app/api/link-preview/route.ts
1104
+ export { linkPreviewHandler as GET } from "@lumir-company/editor/api/link-preview";
1105
+ ```
1106
+
1107
+ 패키지에 내장된 핸들러를 re-export하므로 별도 로직 작성이 불필요합니다.
1108
+
1109
+ **2단계: 에디터에 linkPreview prop 설정**
1110
+
1111
+ ```tsx
1112
+ <LumirEditor linkPreview={{ apiEndpoint: "/api/link-preview" }} />
1113
+ ```
1114
+
1115
+ ### 설정 방법 (Remix / SvelteKit)
1116
+
1117
+ 패키지의 `GET` 핸들러는 표준 Web API(`Request`/`Response`)를 사용하므로 Remix, SvelteKit 등에서도 동일하게 사용 가능합니다.
1118
+
1119
+ ```ts
1120
+ // Remix: app/routes/api.link-preview.ts
1121
+ export { linkPreviewHandler as loader } from "@lumir-company/editor/api/link-preview";
1122
+ ```
1123
+
1124
+ ### 설정 방법 (Express / Fastify 등 커스텀 서버)
1125
+
1126
+ 패키지에서 `fetchUrlMetadata`와 `parseMetaTags`를 import하여 직접 라우트를 구현할 수 있습니다.
1127
+
1128
+ ```ts
1129
+ import { fetchUrlMetadata } from "@lumir-company/editor/api/link-preview";
1130
+
1131
+ app.get("/api/link-preview", async (req, res) => {
1132
+ const url = req.query.url as string;
1133
+ if (!url) return res.status(400).json({ error: "url required" });
1134
+
1135
+ try {
1136
+ const metadata = await fetchUrlMetadata(url);
1137
+ res.json(metadata);
1138
+ } catch {
1139
+ res.status(500).json({ error: "Failed to fetch metadata" });
1140
+ }
1141
+ });
1142
+ ```
1143
+
1144
+ ### 주요 기능
1145
+
1146
+ - URL 붙여넣기 시 자동 링크 프리뷰 블록 생성
1147
+ - 슬래시 메뉴(`/`)에서 Link Preview 항목 선택
1148
+ - 드래그 리사이즈 (좌우 너비, 하단 이미지 높이)
1149
+ - 텍스트 링크를 링크 프리뷰로 전환 (링크 툴바 버튼)
1150
+ - 메타데이터에 이미지 없으면 이미지 영역 자동 생략
1151
+ - 에러 카드 클릭 시 링크 이동
1152
+
1153
+ ### 내장 API 핸들러 export 목록
1154
+
1155
+ `@lumir-company/editor/api/link-preview`에서 export되는 항목:
1156
+
1157
+ | Export | 타입 | 설명 |
1158
+ | -------------------- | ------------------------------------------------- | ------------------------------------------------ |
1159
+ | `linkPreviewHandler` | `(request: Request) => Promise<Response>` | 링크 프리뷰 메타데이터 조회 핸들러 (re-export용) |
1160
+ | `fetchUrlMetadata` | `(url: string) => Promise<LinkMetadata>` | 서버에서 직접 메타데이터 조회 |
1161
+ | `parseMetaTags` | `(html: string, baseUrl: string) => LinkMetadata` | HTML에서 OG 메타데이터 파싱 |
1162
+ | `LinkMetadata` | `interface` | 메타데이터 타입 정의 |
1163
+
1164
+ ---
1165
+
1166
+ ## Props API
1167
+
1168
+ ### 핵심 Props
1169
+
1170
+ | Prop | 타입 | 기본값 | 설명 |
1171
+ | ------------------ | ----------------------------------- | ----------- | ----------------------------------------- |
1172
+ | `s3Upload` | `S3UploaderConfig` | `undefined` | S3 업로드 설정 |
1173
+ | `uploadFile` | `(file: File) => Promise<string>` | `undefined` | 커스텀 업로드 함수 |
1174
+ | `onContentChange` | `(blocks) => void` | `undefined` | 콘텐츠 변경 콜백 |
1175
+ | `onImageDelete` | `(imageUrl: string) => void` | `undefined` | 이미지·비디오 삭제 시 콜백 |
1176
+ | `onError` | `(error: LumirEditorError) => void` | `undefined` | 에러 발생 시 콜백 |
1177
+ | `initialContent` | `Block[] \| string` | `undefined` | 초기 콘텐츠 |
1178
+ | `editable` | `boolean` | `true` | 편집 가능 여부 |
1179
+ | `placeholder` | `string` | `undefined` | 빈 블록 안내 텍스트 |
1180
+ | `linkPreview` | `{ apiEndpoint: string }` | `undefined` | 링크 프리뷰 설정 |
1181
+ | `theme` | `"light" \| "dark" \| ThemeObject` | `"light"` | 테마 (커스텀 테마 객체 지원) |
1182
+ | `allowVideoUpload` | `boolean` | `false` | 동영상 업로드 허용 |
1183
+ | `tables` | `TableConfig` | 모두 `true` | 테이블 기능 설정 ([테이블](#테이블) 참고) |
1184
+ | `className` | `string` | `""` | CSS 클래스 |
1185
+ | `maxImageFileSize` | `number` | `undefined` | 이미지 최대 용량(바이트). 미설정 시 10MB |
1186
+ | `maxVideoFileSize` | `number` | `undefined` | 동영상 최대 용량(바이트). 미설정 시 100MB |
1187
+
1188
+ ### S3UploaderConfig
1189
+
1190
+ ```tsx
1191
+ interface S3UploaderConfig {
1192
+ // 필수
1193
+ apiEndpoint: string; // Presigned URL API 엔드포인트
1194
+ env: "development" | "production";
1195
+ path: string; // S3 저장 경로
1196
+
1197
+ // 선택 (파일명 커스터마이징)
1198
+ fileNameTransform?: (nameWithoutExt: string, file: File) => string; // 확장자 제외한 이름 변환
1199
+ appendUUID?: boolean; // true: 파일명 뒤에 UUID 추가 (확장자 앞에 삽입)
1200
+ preserveExtension?: boolean; // false: 확장자를 붙이지 않음 (기본: true)
1201
+
1202
+ // 선택 (업로드 동작)
1203
+ onProgress?: (percent: number) => void; // 업로드 진행률 0~100 콜백 (S3 PUT 시만 호출, 중간 진행률 보간 지원)
1204
+ uploadTimeoutMs?: number; // PUT 타임아웃(ms). 미설정 시 120000(120초). 대용량 동영상 시 연장 권장
1205
+ maxRetries?: number; // PUT 실패 시 재시도 횟수. 기본 2(최대 3회 시도)
1206
+ }
1207
+ ```
1208
+
1209
+ ### 전체 Props
1210
+
1211
+ <details>
1212
+ <summary>전체 Props 보기</summary>
1213
+
1214
+ ```tsx
1215
+ interface LumirEditorProps {
1216
+ // === 에디터 설정 ===
1217
+ initialContent?: DefaultPartialBlock[] | string; // 초기 콘텐츠 (블록 배열 또는 JSON 문자열)
1218
+ initialEmptyBlocks?: number; // 초기 빈 블록 개수 (기본: 3)
1219
+ placeholder?: string; // 빈 블록에 표시할 안내 텍스트 (예: "내용을 입력하세요...")
1220
+ uploadFile?: (file: File) => Promise<string>; // 커스텀 파일 업로드 함수
1221
+ s3Upload?: {
1222
+ apiEndpoint: string;
1223
+ env: "development" | "production";
1224
+ path: string;
1225
+ fileNameTransform?: (nameWithoutExt: string, file: File) => string; // 확장자 제외한 이름 변환
1226
+ appendUUID?: boolean; // UUID 자동 추가 (확장자 앞)
1227
+ preserveExtension?: boolean; // 확장자 자동 붙이기 (기본: true)
1228
+ onProgress?: (percent: number) => void; // 업로드 진행률 0~100 (이미지·동영상 공통, 중간 진행률 보간)
1229
+ uploadTimeoutMs?: number; // PUT 타임아웃(ms). 기본 120000
1230
+ maxRetries?: number; // PUT 재시도 횟수. 기본 2
1231
+ };
1232
+
1233
+ // === 콜백 ===
1234
+ onContentChange?: (blocks: DefaultPartialBlock[]) => void; // 콘텐츠 변경 시 호출
1235
+ onImageDelete?: (imageUrl: string) => void; // 이미지·비디오 삭제 시 호출 (S3 삭제 등)
1236
+ onSelectionChange?: () => void; // 선택 영역 변경 시 호출
1237
+ onError?: (error: LumirEditorError) => void; // 에러 발생 시 호출
1238
+
1239
+ // 기능 설정
1240
+ tables?: {
1241
+ splitCells?: boolean; // 셀 병합/분할 (기본: true)
1242
+ cellBackgroundColor?: boolean; // 셀 배경색 (기본: true)
1243
+ cellTextColor?: boolean; // 셀 글자색 (기본: true)
1244
+ headers?: boolean; // 헤더 행/열 (기본: true)
1245
+ };
1246
+ heading?: { levels?: (1 | 2 | 3 | 4 | 5 | 6)[] }; // 헤딩 레벨 설정 (기본: [1,2,3,4,5,6])
1247
+ defaultStyles?: boolean; // 기본 스타일 활성화 (기본: true)
1248
+ disableExtensions?: string[]; // 비활성화할 확장 기능 목록
1249
+ tabBehavior?: "prefer-navigate-ui" | "prefer-indent"; // 탭 키 동작 (기본: "prefer-navigate-ui")
1250
+ trailingBlock?: boolean; // 마지막에 빈 블록 자동 추가 (기본: true)
1251
+
1252
+ // === UI 설정 ===
1253
+ editable?: boolean; // 편집 가능 여부 (기본: true)
1254
+ theme?: "light" | "dark" | ThemeObject | { light: ThemeObject; dark: ThemeObject }; // 에디터 테마 (기본: "light")
1255
+ formattingToolbar?: boolean; // 서식 툴바 표시 (기본: true)
1256
+ linkToolbar?: boolean; // 링크 툴바 표시 (기본: true)
1257
+ sideMenu?: boolean; // 사이드 메뉴 표시 (기본: true)
1258
+ sideMenuAddButton?: boolean; // 사이드 메뉴 + 버튼 표시 (기본: false, 드래그 핸들만 표시)
1259
+ emojiPicker?: boolean; // 이모지 선택기 표시 (기본: true)
1260
+ filePanel?: boolean; // 파일 패널 표시 (기본: true)
1261
+ tableHandles?: boolean; // 테이블 핸들 표시 (기본: true)
1262
+ floatingMenu?: boolean; // 상단 고정 플로팅 메뉴 표시 (기본: false)
1263
+ floatingMenuPosition?: "sticky" | "fixed"; // 플로팅 메뉴 위치 (기본: "sticky")
1264
+ columnDivider?: boolean; // 2단(다단) 컬럼 사이 중앙 세로 구분선 표시 (기본: false)
1265
+ className?: string; // 컨테이너 CSS 클래스
1266
+
1267
+ // === 링크 프리뷰 설정 ===
1268
+ linkPreview?: {
1269
+ apiEndpoint: string; // 링크 메타데이터를 가져올 API 엔드포인트 (예: "/api/link-preview")
1270
+ };
1271
+
1272
+ // 미디어 업로드 허용 여부 (기본: 모두 비활성)
1273
+ allowVideoUpload?: boolean; // 비디오 업로드 허용 (기본: false)
1274
+ allowAudioUpload?: boolean; // 오디오 업로드 허용 (기본: false)
1275
+ allowFileUpload?: boolean; // 일반 파일 업로드 허용 (기본: false)
1276
+ maxImageFileSize?: number; // 이미지 최대 파일 크기(바이트). 미설정 시 10MB
1277
+ maxVideoFileSize?: number; // 동영상 최대 파일 크기(바이트). 미설정 시 100MB
1278
+ }
1279
+ ```
1280
+
1281
+ </details>
1282
+
1283
+ ---
1284
+
1285
+ ## 사용 예제
1286
+
1287
+ ### 읽기 전용 모드
1288
+
1289
+ ```tsx
1290
+ <LumirEditor
1291
+ editable={false}
1292
+ initialContent={savedContent}
1293
+ sideMenu={false}
1294
+ formattingToolbar={false}
1295
+ />
1296
+ ```
1297
+
1298
+ ### 다크 테마
1299
+
1300
+ ```tsx
1301
+ <LumirEditor theme="dark" className="bg-gray-900 rounded-lg" />
1302
+ ```
1303
+
1304
+ ### 콘텐츠 저장 및 불러오기
1305
+
1306
+ ```tsx
1307
+ import { useState, useEffect } from "react";
1308
+ import { LumirEditor, ContentUtils } from "@lumir-company/editor";
1309
+
1310
+ function EditorWithSave() {
1311
+ const [content, setContent] = useState("");
1312
+
1313
+ // 불러오기
1314
+ useEffect(() => {
1315
+ const saved = localStorage.getItem("content");
1316
+ if (saved && ContentUtils.isValidJSONString(saved)) {
1317
+ setContent(saved);
1318
+ }
1319
+ }, []);
1320
+
1321
+ // 저장
1322
+ const handleChange = (blocks) => {
1323
+ const json = JSON.stringify(blocks);
1324
+ localStorage.setItem("content", json);
1325
+ };
1326
+
1327
+ return (
1328
+ <LumirEditor initialContent={content} onContentChange={handleChange} />
1329
+ );
1330
+ }
1331
+ ```
1332
+
1333
+ ---
1334
+
1335
+ ## 스타일링
1336
+
1337
+ ### Tailwind CSS와 함께 사용
1338
+
1339
+ ```tsx
1340
+ import { LumirEditor, cn } from "@lumir-company/editor";
1341
+
1342
+ <LumirEditor
1343
+ className={cn(
1344
+ "min-h-[400px] rounded-xl",
1345
+ "border border-gray-200 shadow-lg",
1346
+ "focus-within:ring-2 focus-within:ring-blue-500",
1347
+ )}
1348
+ />;
1349
+ ```
1350
+
1351
+ ### 커스텀 스타일
1352
+
1353
+ ```css
1354
+ /* globals.css */
1355
+ .my-editor .bn-editor {
1356
+ padding: 20px 30px;
1357
+ font-size: 16px;
1358
+ line-height: 1.6;
1359
+ }
1360
+
1361
+ .my-editor [data-content-type="heading"] {
1362
+ font-weight: 700;
1363
+ margin-top: 24px;
1364
+ }
1365
+ ```
1366
+
1367
+ ```tsx
1368
+ <LumirEditor className="my-editor" />
1369
+ ```
1370
+
1371
+ ---
1372
+
1373
+ ## 트러블슈팅
1374
+
1375
+ ### 필수 체크리스트
1376
+
1377
+ - [ ] CSS 임포트: `import "@lumir-company/editor/style.css"`
1378
+ - [ ] 컨테이너 높이 설정: 부모 요소에 높이 지정 필수
1379
+ - [ ] Next.js: `dynamic(..., { ssr: false })` 사용
1380
+ - [ ] React 버전: 18.0.0 이상
1381
+
1382
+ ### 자주 발생하는 문제
1383
+
1384
+ #### 1. 에디터가 보이지 않음
1385
+
1386
+ ```tsx
1387
+ // 잘못됨
1388
+ <LumirEditor />;
1389
+
1390
+ // 올바름
1391
+ import "@lumir-company/editor/style.css";
1392
+ <div className="h-[400px]">
1393
+ <LumirEditor />
1394
+ </div>;
1395
+ ```
1396
+
1397
+ #### 2. Next.js Hydration 오류
1398
+
1399
+ ```tsx
1400
+ // 잘못됨
1401
+ import { LumirEditor } from "@lumir-company/editor";
1402
+
1403
+ // 올바름
1404
+ const LumirEditor = dynamic(
1405
+ () =>
1406
+ import("@lumir-company/editor").then((m) => ({ default: m.LumirEditor })),
1407
+ { ssr: false },
1408
+ );
1409
+ ```
1410
+
1411
+ #### 3. 이미지 업로드 실패
1412
+
1413
+ ```tsx
1414
+ // uploadFile 또는 s3Upload 중 하나는 반드시 설정!
1415
+ <LumirEditor
1416
+ s3Upload={{
1417
+ apiEndpoint: "/api/s3/presigned",
1418
+ env: "development",
1419
+ path: "images",
1420
+ }}
1421
+ />
1422
+ ```
1423
+
1424
+ #### 4. 여러 이미지 업로드 시 중복 문제
1425
+
1426
+ ```tsx
1427
+ // 해결: appendUUID 사용
1428
+ <LumirEditor
1429
+ s3Upload={{
1430
+ apiEndpoint: "/api/s3/presigned",
1431
+ env: "production",
1432
+ path: "images",
1433
+ appendUUID: true, // 고유한 파일명 보장
1434
+ }}
1435
+ />
1436
+ ```
1437
+
1438
+ ---
1439
+
1440
+ ## 유틸리티 API
1441
+
1442
+ ### ContentUtils
1443
+
1444
+ ```tsx
1445
+ import { ContentUtils } from "@lumir-company/editor";
1446
+
1447
+ // JSON 검증
1448
+ ContentUtils.isValidJSONString('[{"type":"paragraph"}]'); // true
1449
+
1450
+ // JSON 파싱
1451
+ const blocks = ContentUtils.parseJSONContent(jsonString);
1452
+
1453
+ // 기본 블록 생성
1454
+ const emptyBlock = ContentUtils.createDefaultBlock();
1455
+ ```
1456
+
1457
+ ### createS3Uploader
1458
+
1459
+ ```tsx
1460
+ import { createS3Uploader } from "@lumir-company/editor";
1461
+
1462
+ const uploader = createS3Uploader({
1463
+ apiEndpoint: "/api/s3/presigned",
1464
+ env: "production",
1465
+ path: "uploads",
1466
+ appendUUID: true,
1467
+ });
1468
+
1469
+ // 직접 사용
1470
+ const url = await uploader(imageFile);
1471
+ ```
1472
+
1473
+ ## 관련 링크
1474
+
1475
+ - [npm Package](https://www.npmjs.com/package/@lumir-company/editor)
1476
+ - [BlockNote Documentation](https://www.blocknotejs.org/)
1477
+
1478
+ ---
1479
+
1480
+ ## 변경 로그
1481
+
1482
+ ### v0.4.23 (2026-06-22)
1483
+
1484
+ - **다중 블록(여러 문단/리스트) 선택 시 글자 크기 스테퍼 누적 되던 버그 수정**: BlockNote `getActiveStyles()`는 선택 끝(`$to`)의 마크만 읽어 다중 블록 선택에서 빈 값을 돌려줬고, 그 결과 스테퍼 표기가 14에 고정돼 +/− 연속 클릭이 매번 같은 값을 재적용했음. 선택 범위 전체를 스캔하는 `readSelectionFontSize` + 낙관적 갱신으로 해결(연속 증감 누적, 표기 정상 갱신)
1485
+ - **툴바 드롭다운 조작 시 선택 하이라이트가 사라지던 문제 보완**: 텍스트를 범위 선택한 뒤 글자 크기 등 툴바 드롭다운을 열면 에디터가 blur되어 브라우저 네이티브 선택 하이라이트가 사라졌음(적용 자체는 정상). blur 상태에서도 선택 범위에 인라인 데코레이션을 입히는 `InactiveSelectionExtension` 추가로 선택 영역이 계속 보이도록 (색상 CSS 변수 `--lumir-inactive-selection`)
1486
+
1487
+ ### v0.4.22 (2026-06-22)
1488
+
1489
+ - **글자 크기 1px 단위 조절** *(신규)*
1490
+ - 글자 크기 드롭다운(포매팅 툴바·플로팅 메뉴) 상단에 **−/+ 스테퍼 + 직접 입력** 추가 → 프리셋 사이 값(13·15·17·21px…)을 1px 단위로 지정 (`↑`/`↓` 키로도 증감)
1491
+ - 허용 범위 **8~96px**(범위 밖 입력은 자동 보정), 명시 크기가 없으면 14px 기준으로 증감. 스테퍼 클릭 시 드롭다운은 닫히지 않아 연속 조절 가능
1492
+ - 기존 프리셋(10·12·14·16·18·20·24·28px)·"기본" 리셋은 그대로 유지. 저장 JSON·하위호환(형제 키 직렬화)은 변경 없음
1493
+ - 공개 API에 `FONT_SIZE_MIN`/`FONT_SIZE_MAX`/`FONT_SIZE_DEFAULT_PX`/`FONT_SIZE_STEP` `parseFontSizePx`/`clampFontSizePx`/`toFontSizeValue` 추가
1494
+ - **글자 크기 스타일을 동기 스펙(`createStyleSpec`)으로 변경**: 기존 React 스펙은 span 내용이 비동기 렌더되어, 크기 적용 직후 포매팅 툴바가 좌상단으로 튀는 문제가 있었음(선택 영역 DOM 좌표가 순간 (0,0)으로 측정됨). 동기 렌더로 바꿔 툴바가 선택 위치에 고정됨. HTML 직렬화(`data-style-type`/`data-value`)는 동일
1495
+
1496
+ ### v0.4.21 (2026-06-18)
1497
+
1498
+ - **Word/docx 표 붙여넣기 품질 개선** *(신규)*
1499
+ - **에디터 너비 자동 맞춤**: 에디터보다 넓은 표를 붙여넣으면 각 열 비율을 유지한 채 에디터 너비에 맞춰 축소(가로 스크롤 방지). 열 너비를 colgroup/셀(pt/px/%)에서 읽어 `columnWidths`로 적용
1500
+ - **셀 서식 보존 확대**: 기존(굵게·기울임·밑줄·취소선·글자색·배경색·텍스트 정렬)에 더해 **글자 크기**와 **세로 정렬**(middle/bottom)도 보존
1501
+ - 한계: 글자 크기는 단위 균일 적용, 글꼴(font-family)·정확한 hex 색은 BlockNote 미지원(색은 10색 팔레트 근사)
1502
+
1503
+ ### v0.4.20 (2026-06-18)
1504
+
1505
+ - **2단 컬럼 구분선 생성 선택** *(신규)*
1506
+ - 슬래시 메뉴에서 `2단 컬럼` / `2단 컬럼 (구분선)` 중 골라 삽입 → 선택한 구분선 유무가 그 블록에 **고정**(문서에 `showDivider`로 저장·라운드트립). 구분선 있는/없는 컬럼을 섞어 사용 가능
1507
+ - v0.4.19 전역 `columnDivider` prop과 별개로, **블록별**로 구분선을 지정
1508
+ - **표 셀에서 Ctrl/Cmd+A → 표 전체 선택** *(신규)*
1509
+ - 셀에 포커스(커서/선택) Ctrl/Cmd+A로 표의 모든 셀을 한 번에 선택(일괄 색·정렬·삭제 등에 유용)
1510
+ - 이미 전체가 선택된 상태에서 다시 누르면 문서 전체로 단계적 확장. 표 밖에서는 기본 동작 유지
1511
+
1512
+ ### v0.4.19 (2026-06-17)
1513
+
1514
+ - **2단 컬럼 중앙 세로 구분선 (`columnDivider` 옵션)** *(신규)*
1515
+ - `columnDivider` prop(기본 `false`)으로 2단(다단) 컬럼 사이 중앙에 세로 구분선 표시
1516
+ - 구분선 양쪽에 드래그 핸들(grip) 너비만큼 여백을 핸들과 겹치지 않음
1517
+ - 색·여백을 CSS 변수로 조절: `--lumir-column-divider-color`(기본 `#e5e7eb`), `--lumir-column-grip-space`(기본 28px)
1518
+ - **표 전체 종횡비 고정 스케일 (코너 드래그)** *(신규)*
1519
+ - 우하단 모서리 hover 시 대각 리사이즈 커서 → 드래그로 표 전체를 종횡비 고정 균일 배율로 확대/축소
1520
+ - 모든 열 너비(`colwidth`)·행 높이(`rowHeight`)에 동일 배율 적용 → 행·열 상대 비율과 표 종횡비 유지
1521
+ - focus 없이 hover만으로 동작(코어 hover 상태 기반), 기존 행/열 개별 리사이즈와 충돌 없음
1522
+ - `tableHandles` prop으로 게이트(행/열 리사이즈와 동일)
1523
+
1524
+ ### v0.4.18 (2026-06-17)
1525
+
1526
+ - **표 열 삭제 버그 수정 (병합 셀 포함)**
1527
+ - 그립 메뉴로 첫 열/행 삭제 시, 그립 클릭으로 ProseMirror 선택이 표 밖으로 빠져 삭제가 무시되던 문제 수정(삭제 대상 표를 포커스 표 기준으로 결정적으로 탐색)
1528
+ - **세로 병합(rowspan) 인접 열**이 있을 때 첫 열이 삭제되지 않던 문제 수정 — 코어 prosemirror-tables가 행의 유일 셀을 지울 때 빈 셀을 남겨(`tableRow+` 스키마) `fixTables`가 원복하던 케이스. 열 삭제를 표 재구성 방식으로 변경해 colspan/rowspan 병합을 안전하게 처리
1529
+ - 병합 셀이축소로 collapse될 **행 높이를 보존**(원래 차지하던 높이 합을 유지)
1530
+
1531
+ ### v0.4.17 (2026-06-16)
1532
+
1533
+ - **표 높이(세로) 리사이즈**
1534
+ - 경계 hover → 드래그로 행 높이 조절(가로 열 리사이즈와 대칭). 드래그 중 셀 높이가 마우스를 따라 실시간 반영
1535
+ - 높이는 `rowHeight` attr로 저장·라운드트립. 행/열 추가·구조 편집 시에도 보존
1536
+ - **표 블록 정렬(좌/가운데/우)**
1537
+ - 상단 포매팅 툴바 + 블록 드래그핸들 메뉴에서 전체를 에디터 영역 기준 좌/가운데/우 정렬
1538
+ - **표 하단 여백 축소**: 표 아래 불필요한 예약 공백(약 16px) 제거(핸들 여백은 유지)
1539
+ - **2단 컬럼(다단) 레이아웃** *(신규)*
1540
+ - 슬래시 메뉴 `/2단 컬럼`으로 좌우 2단 삽입, 각 단에 일반 블록 자유 배치·편집
1541
+ - **블록 DnD**: 블록을 다른 블록의 좌/우 가장자리로 끌어다 놓으면 2단 컬럼 생성(노션식, 세로 드롭 인디케이터)
1542
+ - 빈 컬럼/1단 columnList 자동 정리 등 문서 불변식 보호
1543
+ - 공식 `@blocknote/xl-multi-column`(AGPL) 대신 MIT 안전 자체 구현
1544
+ - *제한(후속 예정)*: 컬럼 안↔밖 DnD/3단 추가, 컬럼 너비 리사이즈, 다중 블록 드래그
1545
+
1546
+ ### v0.4.16 (2026-06-05)
1547
+
1548
+ - **인라인 글자 크기 (Font Size)**
1549
+ - 포매팅 툴바·상단 고정 툴바(FloatingMenu)에 글자 크기 드롭다운 추가 (기본 + 10~28px 프리셋 8단계)
1550
+ - 커스텀 `fontSize` 스타일 스펙 등록 (`FontSize` export)
1551
+ - **구버전 호환 직렬화**: 저장 JSON에는 `styles.fontSize` 대신 styled-text 형제 키 `fontSize`로 기록 — fontSize 스펙이 없는 구버전 SDK(≤0.4.15)에서도 파싱 오류 없이 로드(글자 크기만 무시)
1552
+ - `liftFontSize`/`lowerFontSize` 변환 유틸 및 `SerializedStyledText` 타입 공개 export
1553
+ - **`floatingMenu` 사용 시 팝업 포매팅 툴바 동작 개선**
1554
+ - 일반 텍스트 선택 시 선택 팝업 툴바를 표시하지 않음 (상단 고정 툴바와 중복 + 상단 툴바에서 스타일 적용 직후 팝업이 잘못된 위치(0,0)에 재표시되던 문제 수정)
1555
+ - 테이블 컨텍스트(셀 병합·세로 정렬·셀 배경)와 이미지/노드 선택(캡션·교체·다운로드 등) 팝업에만 있는 도구이므로 기존대로 팝업 표시
1556
+
1557
+ ### v0.4.15 (2026-06-05)
1558
+
1559
+ - **Notion 스타일 테이블색상·정렬·포커스 핸들**
1560
+ - 셀 focus 시 상(열)·좌(행)·우(셀) gutter/grip 표시, grip 클릭으로 행·열·셀 드롭다운 메뉴 (`LumirTableHandlesController`)
1561
+ - 배경색 지원: 셀 메뉴의 "색" 항목 및 플로팅 툴바에서 다중 셀 드래그 선택 후 일괄 적용
1562
+ - 텍스트 색·배경 vs 셀 배경을 구분한 컨텍스트 라벨 색상 컨트롤
1563
+ - 상단 고정 툴바(FloatingMenu)의 정렬·배경색 버튼이 테이블 셀에 올바르게 적용되도록 수정
1564
+ - 행/열 grip 메뉴가 열려 범위 하이라이트 중일 우측 grip 미노출 처리
1565
+
1566
+ ### v0.4.14 (2026-05-29)
1567
+
1568
+ - **Excel/스프레드시트 셀 붙여넣기 → 편집 가능한 테이블**
1569
+ - Excel 복사 시 클립보드의 비트맵 이미지가 업로드되어 테이블이 무시되던 문제 수정
1570
+ - 클립보드 HTML에 `<table>`이 있으면 이미지보다 우선 파싱하여 실제 테이블 블록 생성
1571
+ - Excel 서식(셀 배경, 글자색, 정렬, 굵게/기울임/밑줄)을 BlockNote 속성으로 매핑
1572
+
1573
+ ### v0.4.13 (2026-04-03)
1574
+
1575
+ - **@tiptap/core 외부화(externalize)**
1576
+ - 번들에 포함된 @tiptap/core 중복으로 발생하던 `proseMirrorPlugins` 런타임 에러 수정
1577
+ - `@tiptap/core`를 peerDependency전환하고 빌드에서 external 처리
1578
+
1579
+ ### v0.4.12 (2026-04-03)
1580
+
1581
+ - **Numbered List & Bullet List font size 14px**
1582
+ - Numbered List & Bullet List의 font size 14px로 일반 텍스트 크기와 통일성 있게 변경
1583
+
1584
+ ### v0.4.10 (2026-03-18)
1585
+
1586
+ - **동영상·이미지 업로드 진행률 표시**
1587
+ - S3 업로드 시 진행률이 0만 보이다가 100으로 바로 완료되던 문제 개선
1588
+ - `xhr.upload.onprogress`와 **보간 타이머**를 함께 사용해 중간 진행률(0→…→100)이 부드럽게 갱신되도록 변경
1589
+ - 업로드 시작 직후 `onProgress(0)` 호출, 완료 시 `onProgress(100)` 보장
1590
+ - README: `s3Upload.onProgress` 설명 및 업로드 진행률 표시 섹션 추가
1591
+ - README: `S3UploaderConfig`에 `onProgress`, `uploadTimeoutMs`, `maxRetries` 문서화
1592
+
1593
+ ### v0.4.9 (2026-03-17)
1594
+
1595
+ - **업로드 용량·타임아웃 사용자 설정**
1596
+ - `maxImageFileSize`, `maxVideoFileSize` prop 추가 (미설정 시 기본 10MB/100MB)
1597
+ - `isImageFile(file, maxSize?)`, `isVideoFile(file, maxSize?)` 시그니처 확장
1598
+ - README: 용량 및 타임아웃 설정 예시 및 `s3Upload.uploadTimeoutMs` 안내 보강
1599
+
1600
+ ### v0.4.8 (2026-03)
1601
+
1602
+ - README update (video & image upload)
1603
+ - 버전 배포
1604
+
1605
+ ### v0.4.6 (2026-03)
1606
+
1607
+ - **README: S3 Presigned URL API**
1608
+ - Next.js App Router용 `app/api/s3/presigned/route.ts` 구현 예시 추가 (PutObjectCommand, getSignedUrl)
1609
+ - Next.js가 아닌 프로젝트: Express 예시 및 Remix/SvelteKit 등 동일 패턴 안내
1610
+ - **README: 이미지·동영상 업로드 경로 분리**
1611
+ - `fileNameTransform`으로 이미지/동영상 prefix 분리 (`images/`, `videos/`) 예시 결과 경로 설명 추가
1612
+
1613
+ ### v0.4.5 (2026-03-06)
1614
+
1615
+ - **README: 이미지·동영상 업로드**
1616
+ - 지원 형식/용량, 삽입 경로, 설정 방법(s3Upload / uploadFile), 저장 데이터 구조, 삭제 콜백, 에러 처리 정리
1617
+ - 동영상 블록은 직접 재생 가능한 파일 URL만 지원한다는 안내 추가 (YouTube/Vimeo 링크 미지원)
1618
+
1619
+ ### v0.4.4 (2026-03-05)
1620
+
1621
+ - **Link Preview API 핸들러 내장**
1622
+ - `@lumir-company/editor/api/link-preview` 서브패스 export 추가
1623
+ - 표준 Web API (`Request`/`Response`) 기반으로 Next.js, Remix, SvelteKit 호환
1624
+ - 소비자는 1줄 re-export만으로 API 라우트 설정 가능 (220줄 route.ts 제거)
1625
+ - `linkPreviewHandler`, `fetchUrlMetadata`, `parseMetaTags` 함수 export
1626
+ - **Placeholder 문서화**
1627
+ - Placeholder 사용 가이드 추가
1628
+ - **README 개선**
1629
+ - 링크 프리뷰 서버사이드 요구사항 및 설정 방법 가이드 추가
1630
+ - 내장 API 핸들러 export 목록 문서화
1631
+ - Props API 테이블에 `placeholder`, `linkPreview` 추가
1632
+
1633
+ ### v0.4.3 (2026-02-23)
1634
+
1635
+ - **링크 프리뷰 기능 추가**
1636
+ - `linkPreview` prop으로 링크 미리보기 활성화 (카카오톡 스타일 OG 카드)
1637
+ - URL 붙여넣기 자동 링크 프리뷰 블록 생성 (빈 블록이면 교체, 텍스트 있으면 하단 삽입)
1638
+ - 슬래시 메뉴(`/`)에서 Link Preview 항목 추가
1639
+ - 드래그 리사이즈 지원 (좌우 너비, 하단 이미지 높이 조절)
1640
+ - 메타데이터에 이미지 없을 경우 이미지 영역 생략
1641
+ - 에러 카드 클릭 시 링크 이동 지원
1642
+ - `fetchLinkMetadata`, `clearMetadataCache`, `LinkMetadata` 타입 export
1643
+ - **링크 툴바 커스텀**
1644
+ - 텍스트 링크를 링크 프리뷰 블록으로 전환하는 버튼 추가 (`replaceBlocks` 사용)
1645
+ - `linkPreview.apiEndpoint` 설정 시에만 전환 버튼 표시
1646
+ - **placeholder prop 추가**
1647
+ - `placeholder` prop으로 에디터 블록 안내 텍스트 설정
1648
+ - **이미지 삭제 기능 추가**
1649
+ - `onImageDelete` 콜백 prop 추가 - 에디터에서 이미지 삭제 시 호출
1650
+ - S3 외부 스토리지에서 이미지 자동 삭제 지원
1651
+ - 지연 삭제 패턴으로 Undo/Redo 대응 가능
1652
+ - 이미지 URL 추출 및 삭제 감지 헬퍼 함수 내장
1653
+ - **보안 강화**
1654
+ - URL 이스케이프 처리 추가 (XSS 방지)
1655
+ - LinkButton: `javascript:`, `data:`, `vbscript:`, `file:` 프로토콜 차단
1656
+ - 위험한 URL 입력 에러 메시지 표시
1657
+ - **링크 삽입 버그 수정**
1658
+ - 플로팅 메뉴 링크 버튼: 텍스트 미선택 시에도 URL 텍스트로 링크 삽입 지원
1659
+ - `editor.focus()` 호출로 선택 상태 복원
1660
+ - **README 개선**
1661
+ - 링크 프리뷰 사용 가이드 및 API 라우트 예시 추가
1662
+ - 이미지 삭제 섹션 추가 (지연 삭제 예시 포함)
1663
+ - S3 삭제 API 구현 예시 추가
1664
+ - Props API 문서 업데이트
1665
+
1666
+ ### v0.4.2 (2026-02-23)
1667
+
1668
+ - **코드 구조 리팩토링**
1669
+ - FloatingMenu 컴포넌트 분리 (Icons, 개별 버튼 컴포넌트)
1670
+ - 색상 상수 별도 파일로 분리 (`constants/colors.ts`)
1671
+ - 미사용 기능 제거 (FontSelect, FontSizeControl)
1672
+ - **에러 처리 개선**
1673
+ - `LumirEditorError` 커스텀 에러 클래스 추가
1674
+ - `onError` 콜백 prop 추가 - 에러 발생 시 사용자 정의 핸들링 가능
1675
+ - 에러 발생 사용자 친화적 토스트 메시지 자동 표시
1676
+ - **HTML 미리보기 개선**
1677
+ - sandbox 설정 명확화 (JavaScript 의도적 비활성화)
1678
+ - 드래그 리사이즈, 새 창 열기, 다운로드 기능 문서화
1679
+ - **타입 개선**
1680
+ - `LumirErrorCode`, `LumirErrorDetails` 타입 export
1681
+ - `ColorItem` 타입 export
1682
+
1683
+ ### v0.4.1 (2026-01-15)
1684
+
1685
+ - `preserveExtension` prop 추가 - 확장자 자동 붙이기 제어 (기본: true)
1686
+ - **중요**: 파일명 변환 확장자 위치 수정 (확장자가 항상 맨 뒤에 오도록)
1687
+ - **Breaking Change**: `fileNameTransform` 파라미터 변경 - 이제 확장자 제외한 파일명만 전달됨
1688
+ - 이전: `fileNameTransform: (originalName, file) => ...` → originalName에 확장자 포함
1689
+ - 변경: `fileNameTransform: (nameWithoutExt, file) => ...` → nameWithoutExt에 확장자 제외
1690
+ - 확장자 제거 사용 사례 문서화
1691
+ - README 예제 설명 개선
1692
+
1693
+ ### v0.4.0 (2026-01-15)
1694
+
1695
+ - 파일명 변환 콜백 (`fileNameTransform`) 추가
1696
+ - UUID 자동 추가 옵션 (`appendUUID`) 추가
1697
+ - 여러 이미지 동시 업로드 시 중복 문제 해결
1698
+ - 문서 대폭 개선
1699
+
1700
+ ### v0.3.3 (2025-12-11)
1701
+
1702
+ - 에디터 재생성 방지 최적화
1703
+ - 타입 정의 개선
1704
+
1705
+ ## 📦 배포 (자동)
1706
+
1707
+ 이 패키지(`@lumir-company/editor`)는 **release-please** + self-hosted GitHub Actions 러너로 자동 배포됩니다. 버전을 수동으로 올리거나 `npm publish`를 직접 실행할 필요가 없습니다.
1708
+
1709
+ ### 버전 규칙 (Semantic Versioning)
1710
+
1711
+ 커밋 메시지([Conventional Commits](https://www.conventionalcommits.org/))가 다음 버전을 자동 결정합니다:
1712
+
1713
+ | 커밋 prefix | 버전 증가 | 예시 |
1714
+ | --- | --- | --- |
1715
+ | `fix: …` | **patch** | 1.2.3 → 1.2.4 |
1716
+ | `feat: …` | **minor** | 1.2.3 → 1.3.0 |
1717
+ | `feat!: …` 또는 본문 `BREAKING CHANGE:` | **major** | 1.2.3 → 2.0.0 |
1718
+ | `chore:` `docs:` `refactor:` `test:` 등 | 릴리스 없음 | — |
1719
+
1720
+ ### 배포 흐름
1721
+
1722
+ 1. `master` 브랜치에 conventional commit을 push 한다.
1723
+ 2. **release-please**가 `chore(master): release X.Y.Z` 형태의 **Release PR**을 자동 생성한다 (버전 bump + `CHANGELOG.md` 갱신).
1724
+ 3. 그 PR을 검토 후 **머지**한다. ← 사람이 하는 유일한 단계
1725
+ 4. 머지되면 자동으로 **Git 태그 + GitHub Release 생성 + npm publish** 가 실행된다 (`.github/workflows/release.yml`, self-hosted 러너 `lumir-ci`).
1726
+
1727
+ > npm 인증은 repo secret `NPM_TOKEN`(Automation/Publish 토큰), release-please는 `RELEASE_PLEASE_TOKEN`(PAT)을 사용한다.
1728
+