@lumir-company/editor 0.2.0 → 0.3.3

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/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  // src/components/LumirEditor.tsx
4
- import { useEffect, useMemo } from "react";
4
+ import { useEffect, useMemo, useCallback, useState } from "react";
5
5
  import {
6
6
  useCreateBlockNote,
7
7
  SideMenu as BlockSideMenu,
@@ -17,6 +17,62 @@ function cn(...inputs) {
17
17
  return inputs.filter(Boolean).join(" ");
18
18
  }
19
19
 
20
+ // src/utils/s3-uploader.ts
21
+ var createS3Uploader = (config) => {
22
+ const { apiEndpoint, env, path } = config;
23
+ if (!apiEndpoint || apiEndpoint.trim() === "") {
24
+ throw new Error(
25
+ "apiEndpoint is required for S3 upload. Please provide a valid API endpoint."
26
+ );
27
+ }
28
+ if (!env) {
29
+ throw new Error("env is required. Must be 'development' or 'production'.");
30
+ }
31
+ if (!path || path.trim() === "") {
32
+ throw new Error("path is required and cannot be empty.");
33
+ }
34
+ const generateHierarchicalFileName = (file) => {
35
+ const now = /* @__PURE__ */ new Date();
36
+ const filename = file.name;
37
+ return `${env}/${path}/${filename}`;
38
+ };
39
+ return async (file) => {
40
+ try {
41
+ if (!apiEndpoint || apiEndpoint.trim() === "") {
42
+ throw new Error(
43
+ "Invalid apiEndpoint: Cannot upload file without a valid API ENDPOINT"
44
+ );
45
+ }
46
+ const fileName = generateHierarchicalFileName(file);
47
+ const response = await fetch(
48
+ `${apiEndpoint}?key=${encodeURIComponent(fileName)}`
49
+ );
50
+ if (!response.ok) {
51
+ const errorText = await response.text() || "";
52
+ throw new Error(
53
+ `Failed to get presigned URL: ${response.statusText}, ${errorText}`
54
+ );
55
+ }
56
+ const responseData = await response.json();
57
+ const { presignedUrl, publicUrl } = responseData;
58
+ const uploadResponse = await fetch(presignedUrl, {
59
+ method: "PUT",
60
+ headers: {
61
+ "Content-Type": file.type || "application/octet-stream"
62
+ },
63
+ body: file
64
+ });
65
+ if (!uploadResponse.ok) {
66
+ throw new Error(`Failed to upload file: ${uploadResponse.statusText}`);
67
+ }
68
+ return publicUrl;
69
+ } catch (error) {
70
+ console.error("S3 upload failed:", error);
71
+ throw error;
72
+ }
73
+ };
74
+ };
75
+
20
76
  // src/components/LumirEditor.tsx
21
77
  import { jsx, jsxs } from "react/jsx-runtime";
22
78
  var ContentUtils = class {
@@ -69,39 +125,34 @@ var ContentUtils = class {
69
125
  * 콘텐츠 유효성 검증 및 기본값 설정
70
126
  * @param content 사용자 제공 콘텐츠 (객체 배열 또는 JSON 문자열)
71
127
  * @param emptyBlockCount 빈 블록 개수 (기본값: 3)
72
- * @param placeholder 첫 번째 블록의 placeholder 텍스트
73
128
  * @returns 검증된 콘텐츠 배열
74
129
  */
75
- static validateContent(content, emptyBlockCount = 3, placeholder) {
130
+ static validateContent(content, emptyBlockCount = 3) {
76
131
  if (typeof content === "string") {
77
132
  if (content.trim() === "") {
78
- return this.createEmptyBlocks(emptyBlockCount, placeholder);
133
+ return this.createEmptyBlocks(emptyBlockCount);
79
134
  }
80
135
  const parsedContent = this.parseJSONContent(content);
81
136
  if (parsedContent && parsedContent.length > 0) {
82
137
  return parsedContent;
83
138
  }
84
- return this.createEmptyBlocks(emptyBlockCount, placeholder);
139
+ return this.createEmptyBlocks(emptyBlockCount);
85
140
  }
86
141
  if (!content || content.length === 0) {
87
- return this.createEmptyBlocks(emptyBlockCount, placeholder);
142
+ return this.createEmptyBlocks(emptyBlockCount);
88
143
  }
89
144
  return content;
90
145
  }
91
146
  /**
92
147
  * 빈 블록들을 생성합니다
93
148
  * @param emptyBlockCount 생성할 블록 개수
94
- * @param placeholder 첫 번째 블록의 placeholder 텍스트
95
149
  * @returns 생성된 빈 블록 배열
96
150
  */
97
- static createEmptyBlocks(emptyBlockCount, placeholder) {
98
- return Array.from({ length: emptyBlockCount }, (_, index) => {
99
- const block = this.createDefaultBlock();
100
- if (index === 0 && placeholder) {
101
- block.content = [{ type: "text", text: placeholder, styles: {} }];
102
- }
103
- return block;
104
- });
151
+ static createEmptyBlocks(emptyBlockCount) {
152
+ return Array.from(
153
+ { length: emptyBlockCount },
154
+ () => this.createDefaultBlock()
155
+ );
105
156
  }
106
157
  };
107
158
  var EditorConfig = class {
@@ -131,41 +182,32 @@ var EditorConfig = class {
131
182
  * @param userExtensions 사용자 정의 비활성 확장
132
183
  * @param allowVideo 비디오 업로드 허용 여부
133
184
  * @param allowAudio 오디오 업로드 허용 여부
185
+ * @param allowFile 일반 파일 업로드 허용 여부
134
186
  * @returns 비활성화할 확장 기능 목록
135
187
  */
136
- static getDisabledExtensions(userExtensions, allowVideo = false, allowAudio = false) {
188
+ static getDisabledExtensions(userExtensions, allowVideo = false, allowAudio = false, allowFile = false) {
137
189
  const set = new Set(userExtensions ?? []);
138
190
  if (!allowVideo) set.add("video");
139
191
  if (!allowAudio) set.add("audio");
192
+ if (!allowFile) set.add("file");
140
193
  return Array.from(set);
141
194
  }
142
195
  };
143
- var createObjectUrlUploader = async (file) => {
144
- return URL.createObjectURL(file);
196
+ var isImageFile = (file) => {
197
+ return file.size > 0 && (file.type?.startsWith("image/") || !file.type && /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(file.name || ""));
145
198
  };
146
- var fileToBase64 = async (file) => await new Promise((resolve, reject) => {
147
- const reader = new FileReader();
148
- reader.onload = () => resolve(String(reader.result));
149
- reader.onerror = () => reject(new Error("FileReader failed"));
150
- reader.readAsDataURL(file);
151
- });
152
199
  function LumirEditor({
153
200
  // editor options
154
201
  initialContent,
155
202
  initialEmptyBlocks = 3,
156
- placeholder,
157
203
  uploadFile,
158
- pasteHandler,
204
+ s3Upload,
159
205
  tables,
160
206
  heading,
161
- animations = true,
162
207
  defaultStyles = true,
163
208
  disableExtensions,
164
- domAttributes,
165
209
  tabBehavior = "prefer-navigate-ui",
166
210
  trailingBlock = true,
167
- resolveFileUrl,
168
- storeImagesAsBase64 = true,
169
211
  allowVideoUpload = false,
170
212
  allowAudioUpload = false,
171
213
  allowFileUpload = false,
@@ -175,144 +217,135 @@ function LumirEditor({
175
217
  formattingToolbar = true,
176
218
  linkToolbar = true,
177
219
  sideMenu = true,
178
- slashMenu = true,
179
220
  emojiPicker = true,
180
221
  filePanel = true,
181
222
  tableHandles = true,
182
- comments = true,
183
223
  onSelectionChange,
184
224
  className = "",
185
- includeDefaultStyles = true,
186
- sideMenuAddButton = true,
225
+ sideMenuAddButton = false,
187
226
  // callbacks / refs
188
- onContentChange,
189
- editorRef
227
+ onContentChange
190
228
  }) {
229
+ const [isUploading, setIsUploading] = useState(false);
191
230
  const validatedContent = useMemo(() => {
192
- return ContentUtils.validateContent(
193
- initialContent,
194
- initialEmptyBlocks,
195
- placeholder
231
+ return ContentUtils.validateContent(initialContent, initialEmptyBlocks);
232
+ }, [initialContent, initialEmptyBlocks]);
233
+ const tableConfig = useMemo(() => {
234
+ return EditorConfig.getDefaultTableConfig(tables);
235
+ }, [
236
+ tables?.splitCells,
237
+ tables?.cellBackgroundColor,
238
+ tables?.cellTextColor,
239
+ tables?.headers
240
+ ]);
241
+ const headingConfig = useMemo(() => {
242
+ return EditorConfig.getDefaultHeadingConfig(heading);
243
+ }, [heading?.levels?.join(",") ?? ""]);
244
+ const disabledExtensions = useMemo(() => {
245
+ return EditorConfig.getDisabledExtensions(
246
+ disableExtensions,
247
+ allowVideoUpload,
248
+ allowAudioUpload,
249
+ allowFileUpload
196
250
  );
197
- }, [initialContent, initialEmptyBlocks, placeholder]);
251
+ }, [disableExtensions, allowVideoUpload, allowAudioUpload, allowFileUpload]);
252
+ const memoizedS3Upload = useMemo(() => {
253
+ return s3Upload;
254
+ }, [s3Upload?.apiEndpoint, s3Upload?.env, s3Upload?.path]);
198
255
  const editor = useCreateBlockNote(
199
256
  {
200
257
  initialContent: validatedContent,
201
- tables: EditorConfig.getDefaultTableConfig(tables),
202
- heading: EditorConfig.getDefaultHeadingConfig(heading),
203
- animations,
258
+ tables: tableConfig,
259
+ heading: headingConfig,
260
+ animations: false,
261
+ // 기본적으로 애니메이션 비활성화
204
262
  defaultStyles,
205
- // 확장 비활성: 비디오/오디오만 제어(파일 확장은 내부 드롭 로직 의존 → 비활성화하지 않음)
206
- disableExtensions: useMemo(() => {
207
- return EditorConfig.getDisabledExtensions(
208
- disableExtensions,
209
- allowVideoUpload,
210
- allowAudioUpload
211
- );
212
- }, [disableExtensions, allowVideoUpload, allowAudioUpload]),
213
- domAttributes,
263
+ // 확장 비활성: 비디오/오디오/파일 제어
264
+ disableExtensions: disabledExtensions,
214
265
  tabBehavior,
215
266
  trailingBlock,
216
- resolveFileUrl,
217
267
  uploadFile: async (file) => {
218
- const custom = uploadFile;
219
- const fallback = storeImagesAsBase64 ? fileToBase64 : createObjectUrlUploader;
268
+ if (!isImageFile(file)) {
269
+ throw new Error("Only image files are allowed");
270
+ }
220
271
  try {
221
- if (custom) return await custom(file);
222
- return await fallback(file);
223
- } catch (_) {
224
- try {
225
- return await createObjectUrlUploader(file);
226
- } catch {
227
- throw new Error("Failed to process file for upload");
272
+ let imageUrl;
273
+ if (uploadFile) {
274
+ imageUrl = await uploadFile(file);
275
+ } else if (memoizedS3Upload?.apiEndpoint) {
276
+ const s3Uploader = createS3Uploader(memoizedS3Upload);
277
+ imageUrl = await s3Uploader(file);
278
+ } else {
279
+ throw new Error("No upload method available");
228
280
  }
281
+ return imageUrl;
282
+ } catch (error) {
283
+ console.error("Image upload failed:", error);
284
+ throw new Error(
285
+ "Upload failed: " + (error instanceof Error ? error.message : String(error))
286
+ );
229
287
  }
230
288
  },
231
289
  pasteHandler: (ctx) => {
232
290
  const { event, editor: editor2, defaultPasteHandler } = ctx;
233
291
  const fileList = event?.clipboardData?.files ?? null;
234
292
  const files = fileList ? Array.from(fileList) : [];
235
- const accepted = files.filter(
236
- (f) => f.size > 0 && (f.type?.startsWith("image/") || !f.type && /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(f.name || ""))
237
- );
238
- if (files.length > 0 && accepted.length === 0) {
293
+ const acceptedFiles = files.filter(isImageFile);
294
+ if (files.length > 0 && acceptedFiles.length === 0) {
239
295
  event.preventDefault();
240
296
  return true;
241
297
  }
242
- if (accepted.length === 0) return defaultPasteHandler() ?? false;
298
+ if (acceptedFiles.length === 0) {
299
+ return defaultPasteHandler() ?? false;
300
+ }
243
301
  event.preventDefault();
244
302
  (async () => {
245
- const doUpload = uploadFile ?? (storeImagesAsBase64 ? fileToBase64 : createObjectUrlUploader);
246
- for (const file of accepted) {
247
- try {
248
- const url = await doUpload(file);
249
- editor2.pasteHTML(`<img src="${url}" alt="image" />`);
250
- } catch (err) {
251
- console.warn(
252
- "Image upload failed, skipped:",
253
- file.name || "",
254
- err
255
- );
256
- continue;
303
+ setIsUploading(true);
304
+ try {
305
+ for (const file of acceptedFiles) {
306
+ try {
307
+ const url = await editor2.uploadFile(file);
308
+ editor2.pasteHTML(`<img src="${url}" alt="image" />`);
309
+ } catch (err) {
310
+ console.warn(
311
+ "Image upload failed, skipped:",
312
+ file.name || "",
313
+ err
314
+ );
315
+ }
257
316
  }
317
+ } finally {
318
+ setIsUploading(false);
258
319
  }
259
320
  })();
260
321
  return true;
261
322
  }
262
323
  },
263
324
  [
264
- uploadFile,
265
- pasteHandler,
266
- storeImagesAsBase64,
267
- allowVideoUpload,
268
- allowAudioUpload,
269
- allowFileUpload,
270
- tables?.splitCells,
271
- tables?.cellBackgroundColor,
272
- tables?.cellTextColor,
273
- tables?.headers,
274
- heading?.levels?.join(","),
275
- animations,
325
+ validatedContent,
326
+ tableConfig,
327
+ headingConfig,
276
328
  defaultStyles,
277
- disableExtensions?.join(","),
278
- domAttributes ? JSON.stringify(domAttributes) : void 0,
329
+ disabledExtensions,
279
330
  tabBehavior,
280
331
  trailingBlock,
281
- resolveFileUrl
332
+ uploadFile,
333
+ memoizedS3Upload
282
334
  ]
283
335
  );
284
336
  useEffect(() => {
285
- if (!editor) return;
286
- editor.isEditable = editable;
287
- const el = editor.domElement;
288
- if (!editable) {
289
- if (el) {
290
- el.style.userSelect = "text";
291
- el.style.webkitUserSelect = "text";
292
- }
337
+ if (editor) {
338
+ editor.isEditable = editable;
293
339
  }
294
340
  }, [editor, editable]);
295
341
  useEffect(() => {
296
342
  if (!editor || !onContentChange) return;
297
- let lastContent = "";
298
343
  const handleContentChange = () => {
299
- const topLevelBlocks = editor.topLevelBlocks;
300
- const currentContent = JSON.stringify(topLevelBlocks);
301
- if (lastContent === currentContent) return;
302
- lastContent = currentContent;
303
- onContentChange(topLevelBlocks);
304
- };
305
- editor.onEditorContentChange(handleContentChange);
306
- return () => {
344
+ const blocks = editor.topLevelBlocks;
345
+ onContentChange(blocks);
307
346
  };
347
+ return editor.onEditorContentChange(handleContentChange);
308
348
  }, [editor, onContentChange]);
309
- useEffect(() => {
310
- if (!editorRef) return;
311
- editorRef.current = editor ?? null;
312
- return () => {
313
- if (editorRef) editorRef.current = null;
314
- };
315
- }, [editor, editorRef]);
316
349
  useEffect(() => {
317
350
  const el = editor?.domElement;
318
351
  if (!el) return;
@@ -322,9 +355,6 @@ function LumirEditor({
322
355
  if (hasFiles) {
323
356
  e.preventDefault();
324
357
  e.stopPropagation();
325
- if (typeof e.stopImmediatePropagation === "function") {
326
- e.stopImmediatePropagation();
327
- }
328
358
  }
329
359
  };
330
360
  const handleDrop = (e) => {
@@ -333,23 +363,31 @@ function LumirEditor({
333
363
  if (!hasFiles) return;
334
364
  e.preventDefault();
335
365
  e.stopPropagation();
336
- e.stopImmediatePropagation?.();
337
366
  const items = Array.from(e.dataTransfer.items ?? []);
338
367
  const files = items.filter((it) => it.kind === "file").map((it) => it.getAsFile()).filter((f) => !!f);
339
- const accepted = files.filter(
340
- (f) => f.size > 0 && (f.type?.startsWith("image/") || !f.type && /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(f.name || ""))
341
- );
342
- if (accepted.length === 0) return;
368
+ const acceptedFiles = files.filter(isImageFile);
369
+ if (acceptedFiles.length === 0) return;
343
370
  (async () => {
344
- const doUpload = uploadFile ?? (storeImagesAsBase64 ? fileToBase64 : createObjectUrlUploader);
345
- for (const f of accepted) {
346
- try {
347
- const url = await doUpload(f);
348
- editor?.pasteHTML(`<img src="${url}" alt="image" />`);
349
- } catch (err) {
350
- console.warn("Image upload failed, skipped:", f.name || "", err);
351
- continue;
371
+ setIsUploading(true);
372
+ try {
373
+ for (const file of acceptedFiles) {
374
+ try {
375
+ if (editor?.uploadFile) {
376
+ const url = await editor.uploadFile(file);
377
+ if (url) {
378
+ editor.pasteHTML(`<img src="${url}" alt="image" />`);
379
+ }
380
+ }
381
+ } catch (err) {
382
+ console.warn(
383
+ "Image upload failed, skipped:",
384
+ file.name || "",
385
+ err
386
+ );
387
+ }
352
388
  }
389
+ } finally {
390
+ setIsUploading(false);
353
391
  }
354
392
  })();
355
393
  };
@@ -361,59 +399,66 @@ function LumirEditor({
361
399
  });
362
400
  el.removeEventListener("drop", handleDrop, { capture: true });
363
401
  };
364
- }, [
365
- editor,
366
- uploadFile,
367
- storeImagesAsBase64,
368
- allowVideoUpload,
369
- allowAudioUpload
370
- ]);
371
- const computedSideMenu = sideMenuAddButton ? sideMenu : false;
372
- const DragHandleOnlySideMenu = (props) => {
373
- return /* @__PURE__ */ jsx(BlockSideMenu, { ...props, children: /* @__PURE__ */ jsx(DragHandleButton, { ...props }) });
374
- };
402
+ }, [editor]);
403
+ const computedSideMenu = useMemo(() => {
404
+ return sideMenuAddButton ? sideMenu : false;
405
+ }, [sideMenuAddButton, sideMenu]);
406
+ const DragHandleOnlySideMenu = useMemo(() => {
407
+ return (props) => /* @__PURE__ */ jsx(BlockSideMenu, { ...props, children: /* @__PURE__ */ jsx(DragHandleButton, { ...props }) });
408
+ }, []);
375
409
  return /* @__PURE__ */ jsxs(
376
- BlockNoteView,
410
+ "div",
377
411
  {
378
- className: cn(
379
- includeDefaultStyles && 'lumirEditor w-full h-full min-w-[300px] overflow-auto rounded-md border border-gray-300 focus-within:ring-2 focus-within:ring-black [&_.bn-editor]:px-[12px] [&_[data-content-type="paragraph"]]:text-[14px] bg-white',
380
- className
381
- ),
382
- editor,
383
- editable,
384
- theme,
385
- formattingToolbar,
386
- linkToolbar,
387
- sideMenu: computedSideMenu,
388
- slashMenu: false,
389
- emojiPicker,
390
- filePanel,
391
- tableHandles,
392
- comments,
393
- onSelectionChange,
412
+ className: cn("lumirEditor", className),
413
+ style: { position: "relative" },
394
414
  children: [
395
- /* @__PURE__ */ jsx(
396
- SuggestionMenuController,
415
+ /* @__PURE__ */ jsxs(
416
+ BlockNoteView,
397
417
  {
398
- triggerCharacter: "/",
399
- getItems: async (query) => {
400
- const items = getDefaultReactSlashMenuItems(editor);
401
- const filtered = items.filter((it) => {
402
- const k = (it?.key || "").toString();
403
- if (["video", "audio", "file"].includes(k)) return false;
404
- return true;
405
- });
406
- if (!query) return filtered;
407
- const q = query.toLowerCase();
408
- return filtered.filter(
409
- (it) => (it.title || "").toLowerCase().includes(q) || (it.aliases || []).some(
410
- (a) => a.toLowerCase().includes(q)
411
- )
412
- );
413
- }
418
+ editor,
419
+ editable,
420
+ theme,
421
+ formattingToolbar,
422
+ linkToolbar,
423
+ sideMenu: computedSideMenu,
424
+ slashMenu: false,
425
+ emojiPicker,
426
+ filePanel,
427
+ tableHandles,
428
+ onSelectionChange,
429
+ children: [
430
+ /* @__PURE__ */ jsx(
431
+ SuggestionMenuController,
432
+ {
433
+ triggerCharacter: "/",
434
+ getItems: useCallback(
435
+ async (query) => {
436
+ const items = getDefaultReactSlashMenuItems(editor);
437
+ const filtered = items.filter((item) => {
438
+ const key = (item?.key || "").toString().toLowerCase();
439
+ const title = (item?.title || "").toString().toLowerCase();
440
+ if (["video", "audio", "file"].includes(key)) return false;
441
+ if (title.includes("video") || title.includes("audio") || title.includes("file"))
442
+ return false;
443
+ return true;
444
+ });
445
+ if (!query) return filtered;
446
+ const q = query.toLowerCase();
447
+ return filtered.filter(
448
+ (item) => item.title?.toLowerCase().includes(q) || (item.aliases || []).some(
449
+ (a) => a.toLowerCase().includes(q)
450
+ )
451
+ );
452
+ },
453
+ [editor]
454
+ )
455
+ }
456
+ ),
457
+ !sideMenuAddButton && /* @__PURE__ */ jsx(SideMenuController, { sideMenu: DragHandleOnlySideMenu })
458
+ ]
414
459
  }
415
460
  ),
416
- !sideMenuAddButton && /* @__PURE__ */ jsx(SideMenuController, { sideMenu: DragHandleOnlySideMenu })
461
+ isUploading && /* @__PURE__ */ jsx("div", { className: "lumirEditor-upload-overlay", children: /* @__PURE__ */ jsx("div", { className: "lumirEditor-spinner" }) })
417
462
  ]
418
463
  }
419
464
  );
@@ -422,6 +467,7 @@ export {
422
467
  ContentUtils,
423
468
  EditorConfig,
424
469
  LumirEditor,
425
- cn
470
+ cn,
471
+ createS3Uploader
426
472
  };
427
473
  //# sourceMappingURL=index.mjs.map