@lumir-company/editor 0.4.6 → 0.4.8

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
@@ -18,7 +18,6 @@
18
18
  - [파일명 커스터마이징](#파일명-커스터마이징)
19
19
  - [커스텀 업로더](#2-커스텀-업로더)
20
20
  - [동영상 업로드 및 임베딩](#동영상-업로드-및-임베딩)
21
- - [비디오 플레이어 동작 (커스텀 블록)](#비디오-플레이어-동작-커스텀-블록)
22
21
  - [이미지·동영상 업로드 상세 가이드](#이미지동영상-업로드-상세-가이드)
23
22
  - [이미지·비디오 삭제](#이미지비디오-삭제)
24
23
  - [HTML 미리보기](#html-미리보기)
@@ -146,6 +145,106 @@ production/blog/images/my-photo.png
146
145
  }
147
146
  ```
148
147
 
148
+ 클라이언트는 `apiEndpoint?key={파일키}&contentType={MIME}` 형태로 GET 요청을 보내고, 서버는 위 형식으로 JSON을 반환하면 됩니다.
149
+
150
+ #### S3 Presigned URL API 구현 예시
151
+
152
+ **Next.js (App Router)**
153
+
154
+ 파일: `app/api/s3/presigned/route.ts`
155
+
156
+ ```typescript
157
+ import { NextRequest, NextResponse } from "next/server";
158
+ import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
159
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
160
+
161
+ const s3 = new S3Client({
162
+ region: process.env.AWS_REGION!,
163
+ credentials: {
164
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
165
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
166
+ },
167
+ });
168
+
169
+ export async function GET(req: NextRequest) {
170
+ const { searchParams } = new URL(req.url);
171
+ const key = searchParams.get("key");
172
+ const contentType = searchParams.get("contentType");
173
+
174
+ if (!key) {
175
+ return NextResponse.json({ error: "key is required" }, { status: 400 });
176
+ }
177
+
178
+ const command = new PutObjectCommand({
179
+ Bucket: process.env.AWS_S3_BUCKET!,
180
+ Key: key,
181
+ ContentType: contentType || "application/octet-stream",
182
+ });
183
+
184
+ const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 60 });
185
+ const publicUrl = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
186
+
187
+ return NextResponse.json({ presignedUrl, publicUrl, key });
188
+ }
189
+ ```
190
+
191
+ 필요한 환경 변수: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET`
192
+
193
+ **Next.js가 아닌 프로젝트에서 사용하기**
194
+
195
+ 동일하게 **GET** 요청으로 `key`, `contentType` 쿼리 파라미터를 받아 `presignedUrl`, `publicUrl`을 JSON으로 반환하는 엔드포인트를 구현하면 됩니다.
196
+
197
+ - **Express (Node.js)**
198
+
199
+ ```javascript
200
+ const express = require("express");
201
+ const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
202
+ const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
203
+
204
+ const s3 = new S3Client({
205
+ region: process.env.AWS_REGION,
206
+ credentials: {
207
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
208
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
209
+ },
210
+ });
211
+
212
+ app.get("/api/s3/presigned", async (req, res) => {
213
+ const key = req.query.key;
214
+ const contentType = req.query.contentType || "application/octet-stream";
215
+
216
+ if (!key) {
217
+ return res.status(400).json({ error: "key is required" });
218
+ }
219
+
220
+ const command = new PutObjectCommand({
221
+ Bucket: process.env.AWS_S3_BUCKET,
222
+ Key: key,
223
+ ContentType: contentType,
224
+ });
225
+
226
+ const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 60 });
227
+ const publicUrl = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
228
+
229
+ res.json({ presignedUrl, publicUrl, key });
230
+ });
231
+ ```
232
+
233
+ 에디터 사용 시 `apiEndpoint`만 해당 서버 주소로 맞추면 됩니다.
234
+
235
+ ```tsx
236
+ <LumirEditor
237
+ s3Upload={{
238
+ apiEndpoint: "https://api.myapp.com/api/s3/presigned",
239
+ env: "production",
240
+ path: "uploads",
241
+ }}
242
+ />
243
+ ```
244
+
245
+ - **Remix / SvelteKit / 기타 프레임워크**
246
+ GET 라우트에서 `key`, `contentType`을 받아 `@aws-sdk/client-s3`의 `PutObjectCommand`와 `@aws-sdk/s3-request-presigner`의 `getSignedUrl`로 presigned URL을 생성한 뒤, `{ presignedUrl, publicUrl, key }` 형태로 JSON 응답하면 동일하게 사용할 수 있습니다. CORS가 필요한 경우 해당 도메인을 허용해 두세요.
247
+
149
248
  ---
150
249
 
151
250
  ### 파일명 커스터마이징
@@ -360,24 +459,12 @@ const imageUrl = await s3Uploader(imageFile);
360
459
 
361
460
  ### 데이터 내부 동영상 임베딩
362
461
 
363
- 동영상 블록은 `initialContent` / `onContentChange`에 포함됩니다. 저장 시 `{ type: "video", props: { url: "...", previewWidth?, previewHeight? } }` 형태로 블록이 유지됩니다.
462
+ 동영상 블록은 `initialContent` / `onContentChange`에 포함됩니다. 저장 시 `{ type: "video", props: { url: "..." } }` 형태로 블록이 유지됩니다.
364
463
 
365
- - **재생**: 화면에서 동영상을 재생하려면 `allowVideoUpload={true}`로 두어야 합니다. LumirEditor는 **커스텀 비디오 블록**을 사용하여 플레이어를 렌더링합니다.
464
+ - **재생**: 화면에서 동영상을 재생하려면 `allowVideoUpload={true}`로 두어야 합니다. 이렇게 해야 video 확장이 활성화되어 BlockNote 기본 플레이어가 렌더링됩니다.
366
465
  - `allowVideoUpload={false}`인 상태에서 initialContent에 video 블록만 넣으면 데이터는 보존되지만, 재생 UI는 비활성화된 확장 때문에 표시되지 않을 수 있습니다.
367
466
  - **지원 URL**: 비디오 블록의 `url`은 **직접 재생 가능한 비디오 파일 URL**만 지원합니다(예: S3에 업로드된 `.mp4`, `.webm`, `.ogg`). YouTube·Vimeo 등 스트리밍 페이지 URL(`youtube.com/watch?v=...` 등)은 `<video>` 요소의 `src`로 재생되지 않으므로, 해당 링크를 video 블록 URL로 넣으면 재생되지 않습니다. YouTube 임베드가 필요하면 별도 embed 블록 또는 iframe 삽입 방식을 고려해야 합니다.
368
467
 
369
- ### 비디오 플레이어 동작 (커스텀 블록)
370
-
371
- LumirEditor의 비디오 블록은 BlockNote 기본 플레이어를 대체한 **커스텀 비디오 블록**입니다.
372
-
373
- | 항목 | 동작 |
374
- |------|------|
375
- | **더보기 / 다운로드** | 미노출 (UI에서 제거됨) |
376
- | **우클릭 메뉴** | 비디오 영역에서 컨텍스트 메뉴 비활성화 |
377
- | **사이즈 조절** | 편집 모드에서 좌·우·하단 리사이즈 핸들로 크기 조절 가능. `previewWidth`, `previewHeight`로 블록에 저장됨 (기본 640×360) |
378
-
379
- 블록 데이터에 `previewWidth`(px), `previewHeight`(px)가 없으면 기본값이 적용됩니다.
380
-
381
468
  ---
382
469
 
383
470
  ## 이미지·동영상 업로드 상세 가이드
@@ -460,7 +547,29 @@ LumirEditor의 비디오 블록은 BlockNote 기본 플레이어를 대체한 **
460
547
  />
461
548
  ```
462
549
 
463
- 동영상은 같은 `s3Upload`로 업로드되며, 서버에서 경로를 구분하려면 `path`를 `"media"`로 두거나, `fileNameTransform`에서 이미지/동영상에 따라 다른 prefix를 붙이면 됩니다.
550
+ 동영상은 같은 `s3Upload`로 업로드됩니다. 서버에서 이미지와 동영상을 다른 경로에 두고 싶다면 아래처럼 `fileNameTransform`으로 prefix를 분리하면 됩니다.
551
+
552
+ **이미지·동영상 업로드 경로 분리 (fileNameTransform)**
553
+
554
+ `fileNameTransform`의 두 번째 인자 `file`로 이미지/동영상을 구분해, 파일명 앞에 폴더 prefix를 붙이면 됩니다. 최종 S3 키는 `{env}/{path}/{filename}` 이므로, `filename`에 `images/...` / `videos/...` 를 넣으면 경로가 나뉩니다.
555
+
556
+ ```tsx
557
+ <LumirEditor
558
+ allowVideoUpload={true}
559
+ s3Upload={{
560
+ apiEndpoint: "/api/s3/presigned",
561
+ env: "production",
562
+ path: "uploads", // 공통 상위 경로
563
+ appendUUID: true,
564
+ fileNameTransform: (nameWithoutExt, file) => {
565
+ const isVideo = file.type.startsWith("video/");
566
+ return `${isVideo ? "videos" : "images"}/${nameWithoutExt}`;
567
+ },
568
+ }}
569
+ />
570
+ ```
571
+
572
+ 결과 예: 이미지 → `production/uploads/images/photo_abc123.png`, 동영상 → `production/uploads/videos/clip_def456.mp4`
464
573
 
465
574
  **커스텀 업로더로 이미지·동영상 통합**
466
575
 
@@ -504,17 +613,14 @@ LumirEditor의 비디오 블록은 BlockNote 기본 플레이어를 대체한 **
504
613
  {
505
614
  "type": "video",
506
615
  "props": {
507
- "url": "https://your-cdn.com/videos/clip_xxx.mp4",
508
- "previewWidth": 640,
509
- "previewHeight": 360
616
+ "url": "https://your-cdn.com/videos/clip_xxx.mp4"
510
617
  },
511
618
  "content": [],
512
619
  "children": []
513
620
  }
514
621
  ```
515
622
 
516
- - `url`: 필수. 브라우저에서 직접 재생 가능한 URL이어야 합니다. YouTube/Vimeo 링크가 아니라, 업로드 후 받은 `.mp4` 등 직접 재생 URL만 지원합니다.
517
- - `previewWidth`, `previewHeight`: 선택. 픽셀 단위. 없으면 기본 640×360이 적용되며, 에디터에서 리사이즈하면 저장됩니다.
623
+ `url`은 반드시 **브라우저에서 직접 재생 가능한 URL**이어야 합니다. 동영상은 YouTube/Vimeo 링크가 아니라, 업로드 후 받은 `.mp4` 등 직접 재생 URL만 지원합니다.
518
624
 
519
625
  ### 6. 삭제 시 콜백
520
626
 
@@ -1222,6 +1328,19 @@ const url = await uploader(imageFile);
1222
1328
 
1223
1329
  ## 변경 로그
1224
1330
 
1331
+ ### v0.4.8
1332
+
1333
+ - Redme update (video & image upload)
1334
+ - 버전 배포
1335
+
1336
+ ### v0.4.6
1337
+
1338
+ - **README: S3 Presigned URL API**
1339
+ - Next.js App Router용 `app/api/s3/presigned/route.ts` 구현 예시 추가 (PutObjectCommand, getSignedUrl)
1340
+ - Next.js가 아닌 프로젝트: Express 예시 및 Remix/SvelteKit 등 동일 패턴 안내
1341
+ - **README: 이미지·동영상 업로드 경로 분리**
1342
+ - `fileNameTransform`으로 이미지/동영상 prefix 분리 (`images/`, `videos/`) 예시 및 결과 경로 설명 추가
1343
+
1225
1344
  ### v0.4.5
1226
1345
 
1227
1346
  - **README: 이미지·동영상 업로드**
package/dist/index.d.mts CHANGED
@@ -260,38 +260,6 @@ declare const HtmlPreviewBlock: {
260
260
  }, any, _blocknote_core.InlineContentSchema, _blocknote_core.StyleSchema>;
261
261
  };
262
262
  declare const schema: BlockNoteSchema<_blocknote_core.BlockSchemaFromSpecs<{
263
- video: {
264
- config: {
265
- readonly type: "video";
266
- readonly propSchema: {
267
- readonly url: {
268
- readonly default: "";
269
- };
270
- readonly previewWidth: {
271
- readonly default: 640;
272
- };
273
- readonly previewHeight: {
274
- readonly default: 360;
275
- };
276
- };
277
- readonly content: "none";
278
- };
279
- implementation: _blocknote_core.TiptapBlockImplementation<{
280
- readonly type: "video";
281
- readonly propSchema: {
282
- readonly url: {
283
- readonly default: "";
284
- };
285
- readonly previewWidth: {
286
- readonly default: 640;
287
- };
288
- readonly previewHeight: {
289
- readonly default: 360;
290
- };
291
- };
292
- readonly content: "none";
293
- }, any, _blocknote_core.InlineContentSchema, _blocknote_core.StyleSchema>;
294
- };
295
263
  htmlPreview: {
296
264
  config: {
297
265
  readonly type: "htmlPreview";
@@ -380,6 +348,38 @@ declare const schema: BlockNoteSchema<_blocknote_core.BlockSchemaFromSpecs<{
380
348
  readonly content: "none";
381
349
  }, any, _blocknote_core.InlineContentSchema, _blocknote_core.StyleSchema>;
382
350
  };
351
+ video: {
352
+ config: {
353
+ readonly type: "video";
354
+ readonly propSchema: {
355
+ readonly url: {
356
+ readonly default: "";
357
+ };
358
+ readonly previewWidth: {
359
+ readonly default: 640;
360
+ };
361
+ readonly previewHeight: {
362
+ readonly default: 360;
363
+ };
364
+ };
365
+ readonly content: "none";
366
+ };
367
+ implementation: _blocknote_core.TiptapBlockImplementation<{
368
+ readonly type: "video";
369
+ readonly propSchema: {
370
+ readonly url: {
371
+ readonly default: "";
372
+ };
373
+ readonly previewWidth: {
374
+ readonly default: 640;
375
+ };
376
+ readonly previewHeight: {
377
+ readonly default: 360;
378
+ };
379
+ };
380
+ readonly content: "none";
381
+ }, any, _blocknote_core.InlineContentSchema, _blocknote_core.StyleSchema>;
382
+ };
383
383
  paragraph: {
384
384
  config: {
385
385
  type: "paragraph";
package/dist/index.d.ts CHANGED
@@ -260,38 +260,6 @@ declare const HtmlPreviewBlock: {
260
260
  }, any, _blocknote_core.InlineContentSchema, _blocknote_core.StyleSchema>;
261
261
  };
262
262
  declare const schema: BlockNoteSchema<_blocknote_core.BlockSchemaFromSpecs<{
263
- video: {
264
- config: {
265
- readonly type: "video";
266
- readonly propSchema: {
267
- readonly url: {
268
- readonly default: "";
269
- };
270
- readonly previewWidth: {
271
- readonly default: 640;
272
- };
273
- readonly previewHeight: {
274
- readonly default: 360;
275
- };
276
- };
277
- readonly content: "none";
278
- };
279
- implementation: _blocknote_core.TiptapBlockImplementation<{
280
- readonly type: "video";
281
- readonly propSchema: {
282
- readonly url: {
283
- readonly default: "";
284
- };
285
- readonly previewWidth: {
286
- readonly default: 640;
287
- };
288
- readonly previewHeight: {
289
- readonly default: 360;
290
- };
291
- };
292
- readonly content: "none";
293
- }, any, _blocknote_core.InlineContentSchema, _blocknote_core.StyleSchema>;
294
- };
295
263
  htmlPreview: {
296
264
  config: {
297
265
  readonly type: "htmlPreview";
@@ -380,6 +348,38 @@ declare const schema: BlockNoteSchema<_blocknote_core.BlockSchemaFromSpecs<{
380
348
  readonly content: "none";
381
349
  }, any, _blocknote_core.InlineContentSchema, _blocknote_core.StyleSchema>;
382
350
  };
351
+ video: {
352
+ config: {
353
+ readonly type: "video";
354
+ readonly propSchema: {
355
+ readonly url: {
356
+ readonly default: "";
357
+ };
358
+ readonly previewWidth: {
359
+ readonly default: 640;
360
+ };
361
+ readonly previewHeight: {
362
+ readonly default: 360;
363
+ };
364
+ };
365
+ readonly content: "none";
366
+ };
367
+ implementation: _blocknote_core.TiptapBlockImplementation<{
368
+ readonly type: "video";
369
+ readonly propSchema: {
370
+ readonly url: {
371
+ readonly default: "";
372
+ };
373
+ readonly previewWidth: {
374
+ readonly default: 640;
375
+ };
376
+ readonly previewHeight: {
377
+ readonly default: 360;
378
+ };
379
+ };
380
+ readonly content: "none";
381
+ }, any, _blocknote_core.InlineContentSchema, _blocknote_core.StyleSchema>;
382
+ };
383
383
  paragraph: {
384
384
  config: {
385
385
  type: "paragraph";
package/dist/index.js CHANGED
@@ -1018,39 +1018,48 @@ var VideoBlockCard = ({
1018
1018
  window.removeEventListener("mouseup", onMouseUp);
1019
1019
  };
1020
1020
  }, [resizeParams, localWidth, localHeight, onWidthChange, onHeightChange]);
1021
- const handleLeftDown = (0, import_react4.useCallback)((e) => {
1022
- e.preventDefault();
1023
- e.stopPropagation();
1024
- setResizeParams({
1025
- handleUsed: "left",
1026
- initialWidth: wrapperRef.current?.clientWidth ?? DEFAULT_VIDEO_WIDTH,
1027
- initialHeight: localHeight ?? DEFAULT_VIDEO_HEIGHT,
1028
- initialClientX: e.clientX,
1029
- initialClientY: e.clientY
1030
- });
1031
- }, [localHeight]);
1032
- const handleRightDown = (0, import_react4.useCallback)((e) => {
1033
- e.preventDefault();
1034
- e.stopPropagation();
1035
- setResizeParams({
1036
- handleUsed: "right",
1037
- initialWidth: wrapperRef.current?.clientWidth ?? DEFAULT_VIDEO_WIDTH,
1038
- initialHeight: localHeight ?? DEFAULT_VIDEO_HEIGHT,
1039
- initialClientX: e.clientX,
1040
- initialClientY: e.clientY
1041
- });
1042
- }, [localHeight]);
1043
- const handleBottomDown = (0, import_react4.useCallback)((e) => {
1044
- e.preventDefault();
1045
- e.stopPropagation();
1046
- setResizeParams({
1047
- handleUsed: "bottom",
1048
- initialWidth: wrapperRef.current?.clientWidth ?? DEFAULT_VIDEO_WIDTH,
1049
- initialHeight: localHeight ?? DEFAULT_VIDEO_HEIGHT,
1050
- initialClientX: e.clientX,
1051
- initialClientY: e.clientY
1052
- });
1053
- }, [localHeight]);
1021
+ const handleLeftDown = (0, import_react4.useCallback)(
1022
+ (e) => {
1023
+ e.preventDefault();
1024
+ e.stopPropagation();
1025
+ setResizeParams({
1026
+ handleUsed: "left",
1027
+ initialWidth: wrapperRef.current?.clientWidth ?? DEFAULT_VIDEO_WIDTH,
1028
+ initialHeight: localHeight ?? DEFAULT_VIDEO_HEIGHT,
1029
+ initialClientX: e.clientX,
1030
+ initialClientY: e.clientY
1031
+ });
1032
+ },
1033
+ [localHeight]
1034
+ );
1035
+ const handleRightDown = (0, import_react4.useCallback)(
1036
+ (e) => {
1037
+ e.preventDefault();
1038
+ e.stopPropagation();
1039
+ setResizeParams({
1040
+ handleUsed: "right",
1041
+ initialWidth: wrapperRef.current?.clientWidth ?? DEFAULT_VIDEO_WIDTH,
1042
+ initialHeight: localHeight ?? DEFAULT_VIDEO_HEIGHT,
1043
+ initialClientX: e.clientX,
1044
+ initialClientY: e.clientY
1045
+ });
1046
+ },
1047
+ [localHeight]
1048
+ );
1049
+ const handleBottomDown = (0, import_react4.useCallback)(
1050
+ (e) => {
1051
+ e.preventDefault();
1052
+ e.stopPropagation();
1053
+ setResizeParams({
1054
+ handleUsed: "bottom",
1055
+ initialWidth: wrapperRef.current?.clientWidth ?? DEFAULT_VIDEO_WIDTH,
1056
+ initialHeight: localHeight ?? DEFAULT_VIDEO_HEIGHT,
1057
+ initialClientX: e.clientX,
1058
+ initialClientY: e.clientY
1059
+ });
1060
+ },
1061
+ [localHeight]
1062
+ );
1054
1063
  const resizeCursor = resizeParams ? resizeParams.handleUsed === "bottom" ? "ns-resize" : "ew-resize" : "default";
1055
1064
  const [hovered, setHovered] = (0, import_react4.useState)(false);
1056
1065
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
@@ -1107,6 +1116,7 @@ var VideoBlockCard = ({
1107
1116
  {
1108
1117
  src: url,
1109
1118
  controls: true,
1119
+ controlsList: "nodownload",
1110
1120
  playsInline: true,
1111
1121
  style: {
1112
1122
  width: "100%",
@@ -1561,9 +1571,9 @@ var HtmlPreviewBlock = (0, import_react5.createReactBlockSpec)(
1561
1571
  var schema = import_core.BlockNoteSchema.create({
1562
1572
  blockSpecs: {
1563
1573
  ...import_core.defaultBlockSpecs,
1564
- video: VideoBlock,
1565
1574
  htmlPreview: HtmlPreviewBlock,
1566
- linkPreview: LinkPreviewBlock
1575
+ linkPreview: LinkPreviewBlock,
1576
+ video: VideoBlock
1567
1577
  },
1568
1578
  inlineContentSpecs: import_core.defaultInlineContentSpecs,
1569
1579
  styleSpecs: import_core.defaultStyleSpecs