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