@lumir-company/editor 0.2.1 → 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, useCallback } 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 {
@@ -137,32 +193,21 @@ var EditorConfig = class {
137
193
  return Array.from(set);
138
194
  }
139
195
  };
140
- var createObjectUrlUploader = async (file) => {
141
- return URL.createObjectURL(file);
142
- };
143
196
  var isImageFile = (file) => {
144
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
203
  uploadFile,
204
+ s3Upload,
157
205
  tables,
158
206
  heading,
159
- animations = true,
160
207
  defaultStyles = true,
161
208
  disableExtensions,
162
209
  tabBehavior = "prefer-navigate-ui",
163
210
  trailingBlock = true,
164
- resolveFileUrl,
165
- storeImagesAsBase64 = true,
166
211
  allowVideoUpload = false,
167
212
  allowAudioUpload = false,
168
213
  allowFileUpload = false,
@@ -172,7 +217,6 @@ function LumirEditor({
172
217
  formattingToolbar = true,
173
218
  linkToolbar = true,
174
219
  sideMenu = true,
175
- slashMenu = true,
176
220
  emojiPicker = true,
177
221
  filePanel = true,
178
222
  tableHandles = true,
@@ -182,6 +226,7 @@ function LumirEditor({
182
226
  // callbacks / refs
183
227
  onContentChange
184
228
  }) {
229
+ const [isUploading, setIsUploading] = useState(false);
185
230
  const validatedContent = useMemo(() => {
186
231
  return ContentUtils.validateContent(initialContent, initialEmptyBlocks);
187
232
  }, [initialContent, initialEmptyBlocks]);
@@ -204,33 +249,41 @@ function LumirEditor({
204
249
  allowFileUpload
205
250
  );
206
251
  }, [disableExtensions, allowVideoUpload, allowAudioUpload, allowFileUpload]);
252
+ const memoizedS3Upload = useMemo(() => {
253
+ return s3Upload;
254
+ }, [s3Upload?.apiEndpoint, s3Upload?.env, s3Upload?.path]);
207
255
  const editor = useCreateBlockNote(
208
256
  {
209
257
  initialContent: validatedContent,
210
258
  tables: tableConfig,
211
259
  heading: headingConfig,
212
- animations,
260
+ animations: false,
261
+ // 기본적으로 애니메이션 비활성화
213
262
  defaultStyles,
214
263
  // 확장 비활성: 비디오/오디오/파일 제어
215
264
  disableExtensions: disabledExtensions,
216
265
  tabBehavior,
217
266
  trailingBlock,
218
- resolveFileUrl,
219
267
  uploadFile: async (file) => {
220
268
  if (!isImageFile(file)) {
221
269
  throw new Error("Only image files are allowed");
222
270
  }
223
- const custom = uploadFile;
224
- const fallback = storeImagesAsBase64 ? fileToBase64 : createObjectUrlUploader;
225
271
  try {
226
- if (custom) return await custom(file);
227
- return await fallback(file);
228
- } catch (_) {
229
- try {
230
- return await createObjectUrlUploader(file);
231
- } catch {
232
- 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");
233
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
+ );
234
287
  }
235
288
  },
236
289
  pasteHandler: (ctx) => {
@@ -247,17 +300,22 @@ function LumirEditor({
247
300
  }
248
301
  event.preventDefault();
249
302
  (async () => {
250
- for (const file of acceptedFiles) {
251
- try {
252
- const url = await editor2.uploadFile(file);
253
- editor2.pasteHTML(`<img src="${url}" alt="image" />`);
254
- } catch (err) {
255
- console.warn(
256
- "Image upload failed, skipped:",
257
- file.name || "",
258
- err
259
- );
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
+ }
260
316
  }
317
+ } finally {
318
+ setIsUploading(false);
261
319
  }
262
320
  })();
263
321
  return true;
@@ -267,14 +325,12 @@ function LumirEditor({
267
325
  validatedContent,
268
326
  tableConfig,
269
327
  headingConfig,
270
- animations,
271
328
  defaultStyles,
272
329
  disabledExtensions,
273
330
  tabBehavior,
274
331
  trailingBlock,
275
- resolveFileUrl,
276
332
  uploadFile,
277
- storeImagesAsBase64
333
+ memoizedS3Upload
278
334
  ]
279
335
  );
280
336
  useEffect(() => {
@@ -312,17 +368,26 @@ function LumirEditor({
312
368
  const acceptedFiles = files.filter(isImageFile);
313
369
  if (acceptedFiles.length === 0) return;
314
370
  (async () => {
315
- for (const file of acceptedFiles) {
316
- try {
317
- if (editor?.uploadFile) {
318
- const url = await editor.uploadFile(file);
319
- if (url) {
320
- editor.pasteHTML(`<img src="${url}" alt="image" />`);
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
+ }
321
380
  }
381
+ } catch (err) {
382
+ console.warn(
383
+ "Image upload failed, skipped:",
384
+ file.name || "",
385
+ err
386
+ );
322
387
  }
323
- } catch (err) {
324
- console.warn("Image upload failed, skipped:", file.name || "", err);
325
388
  }
389
+ } finally {
390
+ setIsUploading(false);
326
391
  }
327
392
  })();
328
393
  };
@@ -341,57 +406,68 @@ function LumirEditor({
341
406
  const DragHandleOnlySideMenu = useMemo(() => {
342
407
  return (props) => /* @__PURE__ */ jsx(BlockSideMenu, { ...props, children: /* @__PURE__ */ jsx(DragHandleButton, { ...props }) });
343
408
  }, []);
344
- return /* @__PURE__ */ jsx("div", { className: cn("lumirEditor", className), children: /* @__PURE__ */ jsxs(
345
- BlockNoteView,
409
+ return /* @__PURE__ */ jsxs(
410
+ "div",
346
411
  {
347
- editor,
348
- editable,
349
- theme,
350
- formattingToolbar,
351
- linkToolbar,
352
- sideMenu: computedSideMenu,
353
- slashMenu: false,
354
- emojiPicker,
355
- filePanel,
356
- tableHandles,
357
- onSelectionChange,
412
+ className: cn("lumirEditor", className),
413
+ style: { position: "relative" },
358
414
  children: [
359
- slashMenu && /* @__PURE__ */ jsx(
360
- SuggestionMenuController,
415
+ /* @__PURE__ */ jsxs(
416
+ BlockNoteView,
361
417
  {
362
- triggerCharacter: "/",
363
- getItems: useCallback(
364
- async (query) => {
365
- const items = getDefaultReactSlashMenuItems(editor);
366
- const filtered = items.filter((item) => {
367
- const key = (item?.key || "").toString().toLowerCase();
368
- const title = (item?.title || "").toString().toLowerCase();
369
- if (["video", "audio", "file"].includes(key)) return false;
370
- if (title.includes("video") || title.includes("audio") || title.includes("file"))
371
- return false;
372
- return true;
373
- });
374
- if (!query) return filtered;
375
- const q = query.toLowerCase();
376
- return filtered.filter(
377
- (item) => item.title?.toLowerCase().includes(q) || (item.aliases || []).some(
378
- (a) => a.toLowerCase().includes(q)
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]
379
454
  )
380
- );
381
- },
382
- [editor]
383
- )
455
+ }
456
+ ),
457
+ !sideMenuAddButton && /* @__PURE__ */ jsx(SideMenuController, { sideMenu: DragHandleOnlySideMenu })
458
+ ]
384
459
  }
385
460
  ),
386
- !sideMenuAddButton && /* @__PURE__ */ jsx(SideMenuController, { sideMenu: DragHandleOnlySideMenu })
461
+ isUploading && /* @__PURE__ */ jsx("div", { className: "lumirEditor-upload-overlay", children: /* @__PURE__ */ jsx("div", { className: "lumirEditor-spinner" }) })
387
462
  ]
388
463
  }
389
- ) });
464
+ );
390
465
  }
391
466
  export {
392
467
  ContentUtils,
393
468
  EditorConfig,
394
469
  LumirEditor,
395
- cn
470
+ cn,
471
+ createS3Uploader
396
472
  };
397
473
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/components/LumirEditor.tsx","../src/utils/cn.ts"],"sourcesContent":["'use client';\n\nimport { useEffect, useMemo, useCallback } from 'react';\nimport {\n useCreateBlockNote,\n SideMenu as BlockSideMenu,\n SideMenuController,\n DragHandleButton,\n SuggestionMenuController,\n getDefaultReactSlashMenuItems,\n} from '@blocknote/react';\nimport { BlockNoteView } from '@blocknote/mantine';\nimport { cn } from '../utils/cn';\n\nimport type {\n DefaultPartialBlock,\n LumirEditorProps,\n DefaultBlockSchema,\n DefaultInlineContentSchema,\n DefaultStyleSchema,\n} from '../types';\n\n// ==========================================\n// 유틸리티 클래스들\n// ==========================================\n\n/**\n * 콘텐츠 관리 유틸리티\n * 기본 블록 생성 및 콘텐츠 검증 로직을 담당\n */\nexport class ContentUtils {\n /**\n * JSON 문자열의 유효성을 검증합니다\n * @param jsonString 검증할 JSON 문자열\n * @returns 유효한 JSON 문자열인지 여부\n */\n static isValidJSONString(jsonString: string): boolean {\n try {\n const parsed = JSON.parse(jsonString);\n return Array.isArray(parsed);\n } catch {\n return false;\n }\n }\n\n /**\n * JSON 문자열을 DefaultPartialBlock 배열로 파싱합니다\n * @param jsonString JSON 문자열\n * @returns 파싱된 블록 배열 또는 null (파싱 실패 시)\n */\n static parseJSONContent(jsonString: string): DefaultPartialBlock[] | null {\n try {\n const parsed = JSON.parse(jsonString);\n if (Array.isArray(parsed)) {\n return parsed as DefaultPartialBlock[];\n }\n return null;\n } catch {\n return null;\n }\n }\n\n /**\n * 기본 paragraph 블록 생성\n * @returns 기본 설정이 적용된 DefaultPartialBlock\n */\n static createDefaultBlock(): DefaultPartialBlock {\n return {\n type: 'paragraph',\n props: {\n textColor: 'default',\n backgroundColor: 'default',\n textAlignment: 'left',\n },\n content: [{ type: 'text', text: '', styles: {} }],\n children: [],\n };\n }\n\n /**\n * 콘텐츠 유효성 검증 및 기본값 설정\n * @param content 사용자 제공 콘텐츠 (객체 배열 또는 JSON 문자열)\n * @param emptyBlockCount 빈 블록 개수 (기본값: 3)\n * @returns 검증된 콘텐츠 배열\n */\n static validateContent(\n content?: DefaultPartialBlock[] | string,\n emptyBlockCount: number = 3,\n ): DefaultPartialBlock[] {\n // 1. 문자열인 경우 JSON 파싱 시도\n if (typeof content === 'string') {\n if (content.trim() === '') {\n return this.createEmptyBlocks(emptyBlockCount);\n }\n\n const parsedContent = this.parseJSONContent(content);\n if (parsedContent && parsedContent.length > 0) {\n return parsedContent;\n }\n\n // 파싱 실패 시 빈 블록 생성\n return this.createEmptyBlocks(emptyBlockCount);\n }\n\n // 2. 배열인 경우 기존 로직\n if (!content || content.length === 0) {\n return this.createEmptyBlocks(emptyBlockCount);\n }\n\n return content;\n }\n\n /**\n * 빈 블록들을 생성합니다\n * @param emptyBlockCount 생성할 블록 개수\n * @returns 생성된 빈 블록 배열\n */\n private static createEmptyBlocks(\n emptyBlockCount: number,\n ): DefaultPartialBlock[] {\n return Array.from({ length: emptyBlockCount }, () =>\n this.createDefaultBlock(),\n );\n }\n}\n\n/**\n * 에디터 설정 관리 유틸리티\n * 각종 설정의 기본값과 검증 로직을 담당\n */\nexport class EditorConfig {\n /**\n * 테이블 설정 기본값 적용\n * @param userTables 사용자 테이블 설정\n * @returns 기본값이 적용된 테이블 설정\n */\n static getDefaultTableConfig(userTables?: LumirEditorProps['tables']) {\n return {\n splitCells: userTables?.splitCells ?? true,\n cellBackgroundColor: userTables?.cellBackgroundColor ?? true,\n cellTextColor: userTables?.cellTextColor ?? true,\n headers: userTables?.headers ?? true,\n };\n }\n\n /**\n * 헤딩 설정 기본값 적용\n * @param userHeading 사용자 헤딩 설정\n * @returns 기본값이 적용된 헤딩 설정\n */\n static getDefaultHeadingConfig(userHeading?: LumirEditorProps['heading']) {\n return userHeading?.levels && userHeading.levels.length > 0\n ? userHeading\n : { levels: [1, 2, 3, 4, 5, 6] as (1 | 2 | 3 | 4 | 5 | 6)[] };\n }\n\n /**\n * 비활성화할 확장 기능 목록 생성\n * @param userExtensions 사용자 정의 비활성 확장\n * @param allowVideo 비디오 업로드 허용 여부\n * @param allowAudio 오디오 업로드 허용 여부\n * @param allowFile 일반 파일 업로드 허용 여부\n * @returns 비활성화할 확장 기능 목록\n */\n static getDisabledExtensions(\n userExtensions?: string[],\n allowVideo = false,\n allowAudio = false,\n allowFile = false,\n ): string[] {\n const set = new Set<string>(userExtensions ?? []);\n if (!allowVideo) set.add('video');\n if (!allowAudio) set.add('audio');\n if (!allowFile) set.add('file');\n return Array.from(set);\n }\n}\n\nconst createObjectUrlUploader = async (file: File): Promise<string> => {\n return URL.createObjectURL(file);\n};\n\n// 파일 타입 검증 함수\nconst isImageFile = (file: File): boolean => {\n return (\n file.size > 0 &&\n (file.type?.startsWith('image/') ||\n (!file.type && /\\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(file.name || '')))\n );\n};\n\n// 이미지 파일을 Base64로 변환하는 함수\nconst fileToBase64 = async (file: File): Promise<string> =>\n await new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(new Error('FileReader failed'));\n reader.readAsDataURL(file);\n });\n\nexport default function LumirEditor({\n // editor options\n initialContent,\n initialEmptyBlocks = 3,\n uploadFile,\n tables,\n heading,\n animations = true,\n defaultStyles = true,\n disableExtensions,\n tabBehavior = 'prefer-navigate-ui',\n trailingBlock = true,\n resolveFileUrl,\n storeImagesAsBase64 = true,\n allowVideoUpload = false,\n allowAudioUpload = false,\n allowFileUpload = false,\n // view options\n editable = true,\n theme = 'light',\n formattingToolbar = true,\n linkToolbar = true,\n sideMenu = true,\n slashMenu = true,\n emojiPicker = true,\n filePanel = true,\n tableHandles = true,\n onSelectionChange,\n className = '',\n sideMenuAddButton = false,\n // callbacks / refs\n onContentChange,\n}: LumirEditorProps) {\n const validatedContent = useMemo<DefaultPartialBlock[]>(() => {\n return ContentUtils.validateContent(initialContent, initialEmptyBlocks);\n }, [initialContent, initialEmptyBlocks]);\n\n // 테이블 설정 메모이제이션\n const tableConfig = useMemo(() => {\n return EditorConfig.getDefaultTableConfig(tables);\n }, [\n tables?.splitCells,\n tables?.cellBackgroundColor,\n tables?.cellTextColor,\n tables?.headers,\n ]);\n\n // 헤딩 설정 메모이제이션\n const headingConfig = useMemo(() => {\n return EditorConfig.getDefaultHeadingConfig(heading);\n }, [heading?.levels?.join(',') ?? '']);\n\n // 비활성화 확장 메모이제이션\n const disabledExtensions = useMemo(() => {\n return EditorConfig.getDisabledExtensions(\n disableExtensions,\n allowVideoUpload,\n allowAudioUpload,\n allowFileUpload,\n );\n }, [disableExtensions, allowVideoUpload, allowAudioUpload, allowFileUpload]);\n\n const editor = useCreateBlockNote<\n DefaultBlockSchema,\n DefaultInlineContentSchema,\n DefaultStyleSchema\n >(\n {\n initialContent: validatedContent as DefaultPartialBlock[],\n tables: tableConfig,\n heading: headingConfig,\n animations,\n defaultStyles,\n // 확장 비활성: 비디오/오디오/파일 제어\n disableExtensions: disabledExtensions,\n tabBehavior,\n trailingBlock,\n resolveFileUrl,\n uploadFile: async (file) => {\n // 이미지 파일만 허용 (이미지 전용 에디터)\n if (!isImageFile(file)) {\n throw new Error('Only image files are allowed');\n }\n\n const custom = uploadFile;\n const fallback = storeImagesAsBase64\n ? fileToBase64\n : createObjectUrlUploader;\n try {\n if (custom) return await custom(file);\n return await fallback(file);\n } catch (_) {\n // Fallback to ObjectURL when FileReader or custom upload fails\n try {\n return await createObjectUrlUploader(file);\n } catch {\n throw new Error('Failed to process file for upload');\n }\n }\n },\n pasteHandler: (ctx) => {\n const { event, editor, defaultPasteHandler } = ctx as any;\n const fileList =\n (event?.clipboardData?.files as FileList | null) ?? null;\n const files: File[] = fileList ? Array.from(fileList) : [];\n const acceptedFiles: File[] = files.filter(isImageFile);\n\n // 파일이 있지만 이미지가 없으면 기본 처리 막고 무시\n if (files.length > 0 && acceptedFiles.length === 0) {\n event.preventDefault();\n return true;\n }\n\n // 이미지가 없으면 기본 처리\n if (acceptedFiles.length === 0) {\n return defaultPasteHandler() ?? false;\n }\n\n event.preventDefault();\n (async () => {\n for (const file of acceptedFiles) {\n try {\n // 에디터의 uploadFile 함수 사용 (통일된 로직)\n const url = await editor.uploadFile(file);\n editor.pasteHTML(`<img src=\"${url}\" alt=\"image\" />`);\n } catch (err) {\n console.warn(\n 'Image upload failed, skipped:',\n file.name || '',\n err,\n );\n }\n }\n })();\n return true;\n },\n },\n [\n validatedContent,\n tableConfig,\n headingConfig,\n animations,\n defaultStyles,\n disabledExtensions,\n tabBehavior,\n trailingBlock,\n resolveFileUrl,\n uploadFile,\n storeImagesAsBase64,\n ],\n );\n\n // 편집 가능 여부 설정\n useEffect(() => {\n if (editor) {\n editor.isEditable = editable;\n }\n }, [editor, editable]);\n\n // 콘텐츠 변경 감지\n useEffect(() => {\n if (!editor || !onContentChange) return;\n\n const handleContentChange = () => {\n // BlockNote의 올바른 API 사용\n const blocks = editor.topLevelBlocks as DefaultPartialBlock[];\n onContentChange(blocks);\n };\n\n return editor.onEditorContentChange(handleContentChange);\n }, [editor, onContentChange]);\n\n // 드래그앤드롭 이미지 처리\n useEffect(() => {\n const el = editor?.domElement as HTMLElement | undefined;\n if (!el) return;\n\n const handleDragOver = (e: DragEvent) => {\n if (e.defaultPrevented) return;\n const hasFiles = (\n e.dataTransfer?.types as unknown as string[] | undefined\n )?.includes?.('Files');\n if (hasFiles) {\n e.preventDefault();\n e.stopPropagation();\n }\n };\n\n const handleDrop = (e: DragEvent) => {\n if (!e.dataTransfer) return;\n const hasFiles = (\n (e.dataTransfer.types as unknown as string[] | undefined) ?? []\n ).includes('Files');\n if (!hasFiles) return;\n\n e.preventDefault();\n e.stopPropagation();\n\n const items = Array.from(e.dataTransfer.items ?? []);\n const files = items\n .filter((it) => it.kind === 'file')\n .map((it) => it.getAsFile())\n .filter((f): f is File => !!f);\n\n // 이미지 파일만 허용\n const acceptedFiles = files.filter(isImageFile);\n\n if (acceptedFiles.length === 0) return;\n\n (async () => {\n for (const file of acceptedFiles) {\n try {\n // 에디터의 uploadFile 함수 사용 (일관된 로직)\n if (editor?.uploadFile) {\n const url = await editor.uploadFile(file);\n if (url) {\n editor.pasteHTML(`<img src=\"${url}\" alt=\"image\" />`);\n }\n }\n } catch (err) {\n console.warn('Image upload failed, skipped:', file.name || '', err);\n }\n }\n })();\n };\n\n el.addEventListener('dragover', handleDragOver, { capture: true });\n el.addEventListener('drop', handleDrop, { capture: true });\n\n return () => {\n el.removeEventListener('dragover', handleDragOver, {\n capture: true,\n } as any);\n el.removeEventListener('drop', handleDrop, { capture: true } as any);\n };\n }, [editor]);\n\n // SideMenu 설정 (Add 버튼 제어)\n const computedSideMenu = useMemo(() => {\n return sideMenuAddButton ? sideMenu : false;\n }, [sideMenuAddButton, sideMenu]);\n\n // Add 버튼 없는 사이드 메뉴 (드래그 핸들만) - 메모이제이션\n const DragHandleOnlySideMenu = useMemo(() => {\n return (props: any) => (\n <BlockSideMenu {...props}>\n <DragHandleButton {...props} />\n </BlockSideMenu>\n );\n }, []);\n\n return (\n <div className={cn('lumirEditor', className)}>\n <BlockNoteView\n editor={editor}\n editable={editable}\n theme={theme}\n formattingToolbar={formattingToolbar}\n linkToolbar={linkToolbar}\n sideMenu={computedSideMenu}\n slashMenu={false}\n emojiPicker={emojiPicker}\n filePanel={filePanel}\n tableHandles={tableHandles}\n onSelectionChange={onSelectionChange}>\n {slashMenu && (\n <SuggestionMenuController\n triggerCharacter='/'\n getItems={useCallback(\n async (query: string) => {\n const items = getDefaultReactSlashMenuItems(editor);\n // 비디오, 오디오, 파일 관련 항목 제거\n const filtered = items.filter((item: any) => {\n const key = (item?.key || '').toString().toLowerCase();\n const title = (item?.title || '').toString().toLowerCase();\n // 비디오, 오디오, 파일 관련 항목 제거\n if (['video', 'audio', 'file'].includes(key)) return false;\n if (\n title.includes('video') ||\n title.includes('audio') ||\n title.includes('file')\n )\n return false;\n return true;\n });\n\n if (!query) return filtered;\n const q = query.toLowerCase();\n return filtered.filter(\n (item: any) =>\n item.title?.toLowerCase().includes(q) ||\n (item.aliases || []).some((a: string) =>\n a.toLowerCase().includes(q),\n ),\n );\n },\n [editor],\n )}\n />\n )}\n {!sideMenuAddButton && (\n <SideMenuController sideMenu={DragHandleOnlySideMenu} />\n )}\n </BlockNoteView>\n </div>\n );\n}\n","// clsx와 tailwind-merge를 사용한 className 유틸리티\r\n// 사용자가 직접 설치하도록 권장하거나, 간단한 버전 제공\r\n\r\nexport function cn(...inputs: (string | undefined | null | false)[]) {\r\n return inputs.filter(Boolean).join(' ');\r\n}\r\n"],"mappings":"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","names":["editor"]}
1
+ {"version":3,"sources":["../src/components/LumirEditor.tsx","../src/utils/cn.ts","../src/utils/s3-uploader.ts"],"sourcesContent":["\"use client\";\r\n\r\nimport { useEffect, useMemo, useCallback, useState } from \"react\";\r\nimport {\r\n useCreateBlockNote,\r\n SideMenu as BlockSideMenu,\r\n SideMenuController,\r\n DragHandleButton,\r\n SuggestionMenuController,\r\n getDefaultReactSlashMenuItems,\r\n} from \"@blocknote/react\";\r\nimport { BlockNoteView } from \"@blocknote/mantine\";\r\nimport { cn } from \"../utils/cn\";\r\n\r\nimport type {\r\n DefaultPartialBlock,\r\n LumirEditorProps,\r\n DefaultBlockSchema,\r\n DefaultInlineContentSchema,\r\n DefaultStyleSchema,\r\n} from \"../types\";\r\n\r\nimport { createS3Uploader } from \"../utils/s3-uploader\";\r\n\r\n// ==========================================\r\n// 유틸리티 클래스들\r\n// ==========================================\r\n\r\n/**\r\n * 콘텐츠 관리 유틸리티\r\n * 기본 블록 생성 및 콘텐츠 검증 로직을 담당\r\n */\r\nexport class ContentUtils {\r\n /**\r\n * JSON 문자열의 유효성을 검증합니다\r\n * @param jsonString 검증할 JSON 문자열\r\n * @returns 유효한 JSON 문자열인지 여부\r\n */\r\n static isValidJSONString(jsonString: string): boolean {\r\n try {\r\n const parsed = JSON.parse(jsonString);\r\n return Array.isArray(parsed);\r\n } catch {\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * JSON 문자열을 DefaultPartialBlock 배열로 파싱합니다\r\n * @param jsonString JSON 문자열\r\n * @returns 파싱된 블록 배열 또는 null (파싱 실패 시)\r\n */\r\n static parseJSONContent(jsonString: string): DefaultPartialBlock[] | null {\r\n try {\r\n const parsed = JSON.parse(jsonString);\r\n if (Array.isArray(parsed)) {\r\n return parsed as DefaultPartialBlock[];\r\n }\r\n return null;\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n /**\r\n * 기본 paragraph 블록 생성\r\n * @returns 기본 설정이 적용된 DefaultPartialBlock\r\n */\r\n static createDefaultBlock(): DefaultPartialBlock {\r\n return {\r\n type: \"paragraph\",\r\n props: {\r\n textColor: \"default\",\r\n backgroundColor: \"default\",\r\n textAlignment: \"left\",\r\n },\r\n content: [{ type: \"text\", text: \"\", styles: {} }],\r\n children: [],\r\n };\r\n }\r\n\r\n /**\r\n * 콘텐츠 유효성 검증 및 기본값 설정\r\n * @param content 사용자 제공 콘텐츠 (객체 배열 또는 JSON 문자열)\r\n * @param emptyBlockCount 빈 블록 개수 (기본값: 3)\r\n * @returns 검증된 콘텐츠 배열\r\n */\r\n static validateContent(\r\n content?: DefaultPartialBlock[] | string,\r\n emptyBlockCount: number = 3\r\n ): DefaultPartialBlock[] {\r\n // 1. 문자열인 경우 JSON 파싱 시도\r\n if (typeof content === \"string\") {\r\n if (content.trim() === \"\") {\r\n return this.createEmptyBlocks(emptyBlockCount);\r\n }\r\n\r\n const parsedContent = this.parseJSONContent(content);\r\n if (parsedContent && parsedContent.length > 0) {\r\n return parsedContent;\r\n }\r\n\r\n // 파싱 실패 시 빈 블록 생성\r\n return this.createEmptyBlocks(emptyBlockCount);\r\n }\r\n\r\n // 2. 배열인 경우 기존 로직\r\n if (!content || content.length === 0) {\r\n return this.createEmptyBlocks(emptyBlockCount);\r\n }\r\n\r\n return content;\r\n }\r\n\r\n /**\r\n * 빈 블록들을 생성합니다\r\n * @param emptyBlockCount 생성할 블록 개수\r\n * @returns 생성된 빈 블록 배열\r\n */\r\n private static createEmptyBlocks(\r\n emptyBlockCount: number\r\n ): DefaultPartialBlock[] {\r\n return Array.from({ length: emptyBlockCount }, () =>\r\n this.createDefaultBlock()\r\n );\r\n }\r\n}\r\n\r\n/**\r\n * 에디터 설정 관리 유틸리티\r\n * 각종 설정의 기본값과 검증 로직을 담당\r\n */\r\nexport class EditorConfig {\r\n /**\r\n * 테이블 설정 기본값 적용\r\n * @param userTables 사용자 테이블 설정\r\n * @returns 기본값이 적용된 테이블 설정\r\n */\r\n static getDefaultTableConfig(userTables?: LumirEditorProps[\"tables\"]) {\r\n return {\r\n splitCells: userTables?.splitCells ?? true,\r\n cellBackgroundColor: userTables?.cellBackgroundColor ?? true,\r\n cellTextColor: userTables?.cellTextColor ?? true,\r\n headers: userTables?.headers ?? true,\r\n };\r\n }\r\n\r\n /**\r\n * 헤딩 설정 기본값 적용\r\n * @param userHeading 사용자 헤딩 설정\r\n * @returns 기본값이 적용된 헤딩 설정\r\n */\r\n static getDefaultHeadingConfig(userHeading?: LumirEditorProps[\"heading\"]) {\r\n return userHeading?.levels && userHeading.levels.length > 0\r\n ? userHeading\r\n : { levels: [1, 2, 3, 4, 5, 6] as (1 | 2 | 3 | 4 | 5 | 6)[] };\r\n }\r\n\r\n /**\r\n * 비활성화할 확장 기능 목록 생성\r\n * @param userExtensions 사용자 정의 비활성 확장\r\n * @param allowVideo 비디오 업로드 허용 여부\r\n * @param allowAudio 오디오 업로드 허용 여부\r\n * @param allowFile 일반 파일 업로드 허용 여부\r\n * @returns 비활성화할 확장 기능 목록\r\n */\r\n static getDisabledExtensions(\r\n userExtensions?: string[],\r\n allowVideo = false,\r\n allowAudio = false,\r\n allowFile = false\r\n ): string[] {\r\n const set = new Set<string>(userExtensions ?? []);\r\n if (!allowVideo) set.add(\"video\");\r\n if (!allowAudio) set.add(\"audio\");\r\n if (!allowFile) set.add(\"file\");\r\n return Array.from(set);\r\n }\r\n}\r\n\r\n// 파일 타입 검증 함수\r\nconst isImageFile = (file: File): boolean => {\r\n return (\r\n file.size > 0 &&\r\n (file.type?.startsWith(\"image/\") ||\r\n (!file.type && /\\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(file.name || \"\")))\r\n );\r\n};\r\n\r\nexport default function LumirEditor({\r\n // editor options\r\n initialContent,\r\n initialEmptyBlocks = 3,\r\n uploadFile,\r\n s3Upload,\r\n tables,\r\n heading,\r\n defaultStyles = true,\r\n disableExtensions,\r\n tabBehavior = \"prefer-navigate-ui\",\r\n trailingBlock = true,\r\n allowVideoUpload = false,\r\n allowAudioUpload = false,\r\n allowFileUpload = false,\r\n // view options\r\n editable = true,\r\n theme = \"light\",\r\n formattingToolbar = true,\r\n linkToolbar = true,\r\n sideMenu = true,\r\n emojiPicker = true,\r\n filePanel = true,\r\n tableHandles = true,\r\n onSelectionChange,\r\n className = \"\",\r\n sideMenuAddButton = false,\r\n // callbacks / refs\r\n onContentChange,\r\n}: LumirEditorProps) {\r\n // 이미지 업로드 로딩 상태\r\n const [isUploading, setIsUploading] = useState(false);\r\n const validatedContent = useMemo<DefaultPartialBlock[]>(() => {\r\n return ContentUtils.validateContent(initialContent, initialEmptyBlocks);\r\n }, [initialContent, initialEmptyBlocks]);\r\n\r\n // 테이블 설정 메모이제이션\r\n const tableConfig = useMemo(() => {\r\n return EditorConfig.getDefaultTableConfig(tables);\r\n }, [\r\n tables?.splitCells,\r\n tables?.cellBackgroundColor,\r\n tables?.cellTextColor,\r\n tables?.headers,\r\n ]);\r\n\r\n // 헤딩 설정 메모이제이션\r\n const headingConfig = useMemo(() => {\r\n return EditorConfig.getDefaultHeadingConfig(heading);\r\n }, [heading?.levels?.join(\",\") ?? \"\"]);\r\n\r\n // 비활성화 확장 메모이제이션\r\n const disabledExtensions = useMemo(() => {\r\n return EditorConfig.getDisabledExtensions(\r\n disableExtensions,\r\n allowVideoUpload,\r\n allowAudioUpload,\r\n allowFileUpload\r\n );\r\n }, [disableExtensions, allowVideoUpload, allowAudioUpload, allowFileUpload]);\r\n\r\n // S3 업로드 설정 메모이제이션 (객체 참조 안정화)\r\n const memoizedS3Upload = useMemo(() => {\r\n return s3Upload;\r\n }, [s3Upload?.apiEndpoint, s3Upload?.env, s3Upload?.path]);\r\n\r\n const editor = useCreateBlockNote<\r\n DefaultBlockSchema,\r\n DefaultInlineContentSchema,\r\n DefaultStyleSchema\r\n >(\r\n {\r\n initialContent: validatedContent as DefaultPartialBlock[],\r\n tables: tableConfig,\r\n heading: headingConfig,\r\n animations: false, // 기본적으로 애니메이션 비활성화\r\n defaultStyles,\r\n // 확장 비활성: 비디오/오디오/파일 제어\r\n disableExtensions: disabledExtensions,\r\n tabBehavior,\r\n trailingBlock,\r\n uploadFile: async (file) => {\r\n // 이미지 파일만 허용 (이미지 전용 에디터)\r\n if (!isImageFile(file)) {\r\n throw new Error(\"Only image files are allowed\");\r\n }\r\n\r\n try {\r\n let imageUrl: string;\r\n\r\n // 1. 사용자 정의 uploadFile 우선\r\n if (uploadFile) {\r\n imageUrl = await uploadFile(file);\r\n }\r\n // 2. S3 업로드 (uploadFile 없을 때)\r\n else if (memoizedS3Upload?.apiEndpoint) {\r\n const s3Uploader = createS3Uploader(memoizedS3Upload);\r\n imageUrl = await s3Uploader(file);\r\n }\r\n // 3. 업로드 방법이 없으면 에러\r\n else {\r\n throw new Error(\"No upload method available\");\r\n }\r\n\r\n // BlockNote가 자동으로 이미지 블록을 생성하도록 URL만 반환\r\n return imageUrl;\r\n } catch (error) {\r\n console.error(\"Image upload failed:\", error);\r\n throw new Error(\r\n \"Upload failed: \" +\r\n (error instanceof Error ? error.message : String(error))\r\n );\r\n }\r\n },\r\n pasteHandler: (ctx) => {\r\n const { event, editor, defaultPasteHandler } = ctx as any;\r\n const fileList =\r\n (event?.clipboardData?.files as FileList | null) ?? null;\r\n const files: File[] = fileList ? Array.from(fileList) : [];\r\n const acceptedFiles: File[] = files.filter(isImageFile);\r\n\r\n // 파일이 있지만 이미지가 없으면 기본 처리 막고 무시\r\n if (files.length > 0 && acceptedFiles.length === 0) {\r\n event.preventDefault();\r\n return true;\r\n }\r\n\r\n // 이미지가 없으면 기본 처리\r\n if (acceptedFiles.length === 0) {\r\n return defaultPasteHandler() ?? false;\r\n }\r\n\r\n event.preventDefault();\r\n (async () => {\r\n // 붙여넣기로 여러 이미지 업로드 시 로딩 상태 관리\r\n setIsUploading(true);\r\n try {\r\n for (const file of acceptedFiles) {\r\n try {\r\n // 에디터의 uploadFile 함수 사용 (통일된 로직)\r\n const url = await editor.uploadFile(file);\r\n editor.pasteHTML(`<img src=\"${url}\" alt=\"image\" />`);\r\n } catch (err) {\r\n console.warn(\r\n \"Image upload failed, skipped:\",\r\n file.name || \"\",\r\n err\r\n );\r\n }\r\n }\r\n } finally {\r\n setIsUploading(false);\r\n }\r\n })();\r\n return true;\r\n },\r\n },\r\n [\r\n validatedContent,\r\n tableConfig,\r\n headingConfig,\r\n defaultStyles,\r\n disabledExtensions,\r\n tabBehavior,\r\n trailingBlock,\r\n uploadFile,\r\n memoizedS3Upload,\r\n ]\r\n );\r\n\r\n // 편집 가능 여부 설정\r\n useEffect(() => {\r\n if (editor) {\r\n editor.isEditable = editable;\r\n }\r\n }, [editor, editable]);\r\n\r\n // 콘텐츠 변경 감지\r\n useEffect(() => {\r\n if (!editor || !onContentChange) return;\r\n\r\n const handleContentChange = () => {\r\n // BlockNote의 올바른 API 사용\r\n const blocks = editor.topLevelBlocks as DefaultPartialBlock[];\r\n onContentChange(blocks);\r\n };\r\n\r\n return editor.onEditorContentChange(handleContentChange);\r\n }, [editor, onContentChange]);\r\n\r\n // 드래그앤드롭 이미지 처리\r\n useEffect(() => {\r\n const el = editor?.domElement as HTMLElement | undefined;\r\n if (!el) return;\r\n\r\n const handleDragOver = (e: DragEvent) => {\r\n if (e.defaultPrevented) return;\r\n const hasFiles = (\r\n e.dataTransfer?.types as unknown as string[] | undefined\r\n )?.includes?.(\"Files\");\r\n if (hasFiles) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n }\r\n };\r\n\r\n const handleDrop = (e: DragEvent) => {\r\n if (!e.dataTransfer) return;\r\n const hasFiles = (\r\n (e.dataTransfer.types as unknown as string[] | undefined) ?? []\r\n ).includes(\"Files\");\r\n if (!hasFiles) return;\r\n\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n const items = Array.from(e.dataTransfer.items ?? []);\r\n const files = items\r\n .filter((it) => it.kind === \"file\")\r\n .map((it) => it.getAsFile())\r\n .filter((f): f is File => !!f);\r\n\r\n // 이미지 파일만 허용\r\n const acceptedFiles = files.filter(isImageFile);\r\n\r\n if (acceptedFiles.length === 0) return;\r\n\r\n (async () => {\r\n // 드래그앤드롭으로 여러 이미지 업로드 시 로딩 상태 관리\r\n setIsUploading(true);\r\n try {\r\n for (const file of acceptedFiles) {\r\n try {\r\n // 에디터의 uploadFile 함수 사용 (일관된 로직)\r\n if (editor?.uploadFile) {\r\n const url = await editor.uploadFile(file);\r\n if (url) {\r\n editor.pasteHTML(`<img src=\"${url}\" alt=\"image\" />`);\r\n }\r\n }\r\n } catch (err) {\r\n console.warn(\r\n \"Image upload failed, skipped:\",\r\n file.name || \"\",\r\n err\r\n );\r\n }\r\n }\r\n } finally {\r\n setIsUploading(false);\r\n }\r\n })();\r\n };\r\n\r\n el.addEventListener(\"dragover\", handleDragOver, { capture: true });\r\n el.addEventListener(\"drop\", handleDrop, { capture: true });\r\n\r\n return () => {\r\n el.removeEventListener(\"dragover\", handleDragOver, {\r\n capture: true,\r\n } as any);\r\n el.removeEventListener(\"drop\", handleDrop, { capture: true } as any);\r\n };\r\n }, [editor]);\r\n\r\n // SideMenu 설정 (Add 버튼 제어)\r\n const computedSideMenu = useMemo(() => {\r\n return sideMenuAddButton ? sideMenu : false;\r\n }, [sideMenuAddButton, sideMenu]);\r\n\r\n // Add 버튼 없는 사이드 메뉴 (드래그 핸들만) - 메모이제이션\r\n const DragHandleOnlySideMenu = useMemo(() => {\r\n return (props: any) => (\r\n <BlockSideMenu {...props}>\r\n <DragHandleButton {...props} />\r\n </BlockSideMenu>\r\n );\r\n }, []);\r\n\r\n return (\r\n <div\r\n className={cn(\"lumirEditor\", className)}\r\n style={{ position: \"relative\" }}\r\n >\r\n <BlockNoteView\r\n editor={editor}\r\n editable={editable}\r\n theme={theme}\r\n formattingToolbar={formattingToolbar}\r\n linkToolbar={linkToolbar}\r\n sideMenu={computedSideMenu}\r\n slashMenu={false}\r\n emojiPicker={emojiPicker}\r\n filePanel={filePanel}\r\n tableHandles={tableHandles}\r\n onSelectionChange={onSelectionChange}\r\n >\r\n {\r\n <SuggestionMenuController\r\n triggerCharacter=\"/\"\r\n getItems={useCallback(\r\n async (query: string) => {\r\n const items = getDefaultReactSlashMenuItems(editor);\r\n // 비디오, 오디오, 파일 관련 항목 제거\r\n const filtered = items.filter((item: any) => {\r\n const key = (item?.key || \"\").toString().toLowerCase();\r\n const title = (item?.title || \"\").toString().toLowerCase();\r\n // 비디오, 오디오, 파일 관련 항목 제거\r\n if ([\"video\", \"audio\", \"file\"].includes(key)) return false;\r\n if (\r\n title.includes(\"video\") ||\r\n title.includes(\"audio\") ||\r\n title.includes(\"file\")\r\n )\r\n return false;\r\n return true;\r\n });\r\n\r\n if (!query) return filtered;\r\n const q = query.toLowerCase();\r\n return filtered.filter(\r\n (item: any) =>\r\n item.title?.toLowerCase().includes(q) ||\r\n (item.aliases || []).some((a: string) =>\r\n a.toLowerCase().includes(q)\r\n )\r\n );\r\n },\r\n [editor]\r\n )}\r\n />\r\n }\r\n {!sideMenuAddButton && (\r\n <SideMenuController sideMenu={DragHandleOnlySideMenu} />\r\n )}\r\n </BlockNoteView>\r\n\r\n {/* 이미지 업로드 로딩 스피너 */}\r\n {isUploading && (\r\n <div className=\"lumirEditor-upload-overlay\">\r\n <div className=\"lumirEditor-spinner\" />\r\n </div>\r\n )}\r\n </div>\r\n );\r\n}\r\n","// clsx와 tailwind-merge를 사용한 className 유틸리티\r\n// 사용자가 직접 설치하도록 권장하거나, 간단한 버전 제공\r\n\r\nexport function cn(...inputs: (string | undefined | null | false)[]) {\r\n return inputs.filter(Boolean).join(' ');\r\n}\r\n","export interface S3UploaderConfig {\r\n apiEndpoint: string; // '/api/s3/presigned'(필수)\r\n env: \"production\" | \"development\"; // 환경 (필수)\r\n path: string; // 파일 경로 (필수)\r\n}\r\n\r\nexport const createS3Uploader = (config: S3UploaderConfig) => {\r\n const { apiEndpoint, env, path } = config;\r\n\r\n // 필수 파라미터 검증\r\n if (!apiEndpoint || apiEndpoint.trim() === \"\") {\r\n throw new Error(\r\n \"apiEndpoint is required for S3 upload. Please provide a valid API endpoint.\"\r\n );\r\n }\r\n\r\n if (!env) {\r\n throw new Error(\"env is required. Must be 'development' or 'production'.\");\r\n }\r\n\r\n if (!path || path.trim() === \"\") {\r\n throw new Error(\"path is required and cannot be empty.\");\r\n }\r\n\r\n // 계층 구조 파일명 생성 함수\r\n const generateHierarchicalFileName = (file: File): string => {\r\n const now = new Date();\r\n\r\n // 날짜 (yyyy-mm-dd)\r\n\r\n // 파일명\r\n const filename = file.name;\r\n\r\n // {env}/{path}/{date}/{time}/{filename}\r\n return `${env}/${path}/${filename}`;\r\n };\r\n\r\n return async (file: File): Promise<string> => {\r\n try {\r\n // 파일 업로드 시에도 apiEndpoint 재검증\r\n if (!apiEndpoint || apiEndpoint.trim() === \"\") {\r\n throw new Error(\r\n \"Invalid apiEndpoint: Cannot upload file without a valid API ENDPOINT\"\r\n );\r\n }\r\n\r\n // 1. 계층 구조 파일명 생성\r\n const fileName = generateHierarchicalFileName(file);\r\n\r\n // 2. presigned URL 요청\r\n const response = await fetch(\r\n `${apiEndpoint}?key=${encodeURIComponent(fileName)}`\r\n );\r\n\r\n if (!response.ok) {\r\n const errorText = (await response.text()) || \"\";\r\n throw new Error(\r\n `Failed to get presigned URL: ${response.statusText}, ${errorText}`\r\n );\r\n }\r\n\r\n const responseData = await response.json();\r\n const { presignedUrl, publicUrl } = responseData;\r\n\r\n // 3. S3에 업로드\r\n const uploadResponse = await fetch(presignedUrl, {\r\n method: \"PUT\",\r\n headers: {\r\n \"Content-Type\": file.type || \"application/octet-stream\",\r\n },\r\n body: file,\r\n });\r\n\r\n if (!uploadResponse.ok) {\r\n throw new Error(`Failed to upload file: ${uploadResponse.statusText}`);\r\n }\r\n\r\n // 4. 공개 URL 반환\r\n return publicUrl;\r\n } catch (error) {\r\n console.error(\"S3 upload failed:\", error);\r\n throw error;\r\n }\r\n };\r\n};\r\n"],"mappings":"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","names":["editor"]}
package/dist/style.css CHANGED
@@ -1,37 +1,86 @@
1
- @import '@blocknote/core/fonts/inter.css';
2
- @import '@blocknote/mantine/style.css';
3
- @import '@blocknote/react/style.css';
4
-
5
- .lumirEditor {
6
- width: 100%;
7
- height: 100%;
8
- min-width: 250px;
9
- overflow: auto;
10
- }
11
-
12
- /* 포커스 상태 스타일 */
13
- .lumirEditor:focus-within {
14
- outline: 2px solid transparent;
15
- outline-offset: 2px;
16
- box-shadow: 0 0 0 2px black;
17
- }
18
-
19
- /* BlockNote 에디터 내부 폰트 설정 */
20
- .lumirEditor .bn-editor,
21
- .lumirEditor .bn-inline-content {
22
- font-family: 'Pretendard', 'Noto Sans KR', -apple-system, BlinkMacSystemFont,
23
- 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
24
- }
25
-
26
- /* CSS 변수를 사용한 패딩 설정 */
27
- .lumirEditor .bn-editor {
28
- padding-left: 25px;
29
- padding-right: 10px;
30
- padding-top: 5px;
31
- padding-bottom: 0;
32
- }
33
-
34
- /* 문단 텍스트 크기 설정 */
35
- .lumirEditor [data-content-type='paragraph'] {
36
- font-size: 14px;
37
- }
1
+ @import "@blocknote/core/fonts/inter.css";
2
+ @import "@blocknote/mantine/style.css";
3
+ @import "@blocknote/react/style.css";
4
+
5
+ .lumirEditor {
6
+ width: 100%;
7
+ height: 100%;
8
+ min-width: 200px;
9
+ overflow: visible; /* 슬래시 메뉴가 컨테이너를 넘어서 표시되도록 변경 */
10
+ background-color: #ffffff;
11
+ }
12
+
13
+ /* 에디터 내부 콘텐츠 영역만 스크롤 가능하게 설정 */
14
+ .lumirEditor .bn-container {
15
+ overflow: auto;
16
+ max-height: 100%;
17
+ }
18
+
19
+ /* 슬래시 메뉴(SuggestionMenu)가 컨테이너를 넘어서 표시되도록 */
20
+ .bn-suggestion-menu,
21
+ .bn-slash-menu,
22
+ .mantine-Menu-dropdown,
23
+ .mantine-Popover-dropdown {
24
+ z-index: 9999 !important;
25
+ }
26
+ /* 포커스 상태 스타일 - 테두리 없음 */
27
+ .lumirEditor:focus-within {
28
+ outline: none;
29
+ box-shadow: none;
30
+ }
31
+ .lumirEditor {
32
+ border: none;
33
+ }
34
+
35
+ /* BlockNote 에디터 내부 폰트 설정 */
36
+ .lumirEditor .bn-editor,
37
+ .lumirEditor .bn-inline-content {
38
+ font-family: "Pretendard", "Noto Sans KR", -apple-system, BlinkMacSystemFont,
39
+ "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif;
40
+ }
41
+
42
+ /* CSS 변수를 사용한 패딩 설정 */
43
+ .lumirEditor .bn-editor {
44
+ padding-left: 25px;
45
+ padding-right: 10px;
46
+ padding-top: 5px;
47
+ padding-bottom: 0;
48
+ }
49
+
50
+ /* 문단 텍스트 크기 설정 */
51
+ .lumirEditor [data-content-type="paragraph"] {
52
+ font-size: 14px;
53
+ }
54
+
55
+ /* 이미지 업로드 로딩 스피너 */
56
+ .lumirEditor-upload-overlay {
57
+ position: absolute;
58
+ top: 0;
59
+ left: 0;
60
+ right: 0;
61
+ bottom: 0;
62
+ display: flex;
63
+ align-items: center;
64
+ justify-content: center;
65
+ background-color: rgba(255, 255, 255, 0.8);
66
+ backdrop-filter: blur(2px);
67
+ z-index: 1000;
68
+ }
69
+
70
+ .lumirEditor-spinner {
71
+ width: 30px;
72
+ height: 30px;
73
+ border: 3px solid #f3f3f3;
74
+ border-top: 3px solid #3b82f6;
75
+ border-radius: 50%;
76
+ animation: lumir-spin 1s linear infinite;
77
+ }
78
+
79
+ @keyframes lumir-spin {
80
+ 0% {
81
+ transform: rotate(0deg);
82
+ }
83
+ 100% {
84
+ transform: rotate(360deg);
85
+ }
86
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@lumir-company/editor",
3
- "version": "0.2.1",
3
+ "version": "0.3.3",
4
4
  "private": false,
5
- "description": "Image-only BlockNote based rich text editor with Base64 conversion and theme support",
5
+ "description": "Image-only BlockNote rich text editor with S3 upload and optimized loading spinner",
6
6
  "keywords": [
7
7
  "editor",
8
8
  "blocknote",
@@ -11,9 +11,9 @@
11
11
  "typescript",
12
12
  "image-editor",
13
13
  "wysiwyg",
14
- "base64",
15
- "theme",
16
- "pretendard"
14
+ "s3-upload",
15
+ "loading-spinner",
16
+ "lumir"
17
17
  ],
18
18
  "main": "dist/index.js",
19
19
  "module": "dist/index.mjs",