@lumir-company/editor 0.2.1 → 0.4.0

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.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/components/LumirEditor.tsx","../src/utils/cn.ts"],"sourcesContent":["'use client';\r\n\r\n// 컴포넌트 및 유틸리티 export\r\nexport {\r\n default as LumirEditor,\r\n ContentUtils,\r\n EditorConfig,\r\n} from './components/LumirEditor';\r\nexport { cn } from './utils/cn';\r\n\r\n// 타입 export (별도 파일에서 관리)\r\nexport type {\r\n LumirEditorProps,\r\n EditorType,\r\n DefaultPartialBlock,\r\n DefaultBlockSchema,\r\n DefaultInlineContentSchema,\r\n DefaultStyleSchema,\r\n PartialBlock,\r\n BlockNoteEditor,\r\n} from './types';\r\n","'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":["import_react","editor","BlockSideMenu"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/components/LumirEditor.tsx","../src/utils/cn.ts","../src/utils/s3-uploader.ts"],"sourcesContent":["\"use client\";\r\n\r\n// 컴포넌트 및 유틸리티 export\r\nexport {\r\n default as LumirEditor,\r\n ContentUtils,\r\n EditorConfig,\r\n} from \"./components/LumirEditor\";\r\nexport { cn } from \"./utils/cn\";\r\nexport { createS3Uploader } from \"./utils/s3-uploader\";\r\n\r\n// 타입 export (별도 파일에서 관리)\r\nexport type {\r\n LumirEditorProps,\r\n EditorType,\r\n DefaultPartialBlock,\r\n DefaultBlockSchema,\r\n DefaultInlineContentSchema,\r\n DefaultStyleSchema,\r\n PartialBlock,\r\n BlockNoteEditor,\r\n} from \"./types\";\r\nexport type { S3UploaderConfig } from \"./utils/s3-uploader\";\r\n","\"use client\";\r\n\r\nimport { useEffect, useMemo, useCallback, useState, useRef } 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 // fileNameTransform 콜백을 ref로 관리 (에디터 재생성 방지)\r\n const fileNameTransformRef = useRef(s3Upload?.fileNameTransform);\r\n useEffect(() => {\r\n fileNameTransformRef.current = s3Upload?.fileNameTransform;\r\n }, [s3Upload?.fileNameTransform]);\r\n\r\n // S3 업로드 설정 메모이제이션 (객체 참조 안정화)\r\n // 주의: fileNameTransform은 ref로 관리하므로 의존성에서 제외\r\n const memoizedS3Upload = useMemo(() => {\r\n if (!s3Upload) return undefined;\r\n return {\r\n apiEndpoint: s3Upload.apiEndpoint,\r\n env: s3Upload.env,\r\n path: s3Upload.path,\r\n appendUUID: s3Upload.appendUUID,\r\n // 최신 콜백을 항상 사용하도록 ref를 통해 접근\r\n fileNameTransform: ((originalName: string, file: File) => {\r\n return fileNameTransformRef.current\r\n ? fileNameTransformRef.current(originalName, file)\r\n : originalName;\r\n }) as ((originalName: string, file: File) => string) | undefined,\r\n };\r\n }, [\r\n s3Upload?.apiEndpoint,\r\n s3Upload?.env,\r\n s3Upload?.path,\r\n s3Upload?.appendUUID,\r\n ]);\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 fileNameTransform?: (originalName: string, file: File) => string;\r\n /** true일 경우 파일명 뒤에 UUID를 자동으로 추가합니다 (예: image_abc123.png) */\r\n appendUUID?: boolean;\r\n}\r\n\r\n// UUID 생성 함수 (crypto.randomUUID 또는 폴백)\r\nconst generateUUID = (): string => {\r\n if (typeof crypto !== \"undefined\" && crypto.randomUUID) {\r\n return crypto.randomUUID();\r\n }\r\n // 폴백: 간단한 UUID v4 형식 생성\r\n return \"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\".replace(/[xy]/g, (c) => {\r\n const r = (Math.random() * 16) | 0;\r\n const v = c === \"x\" ? r : (r & 0x3) | 0x8;\r\n return v.toString(16);\r\n });\r\n};\r\n\r\nexport const createS3Uploader = (config: S3UploaderConfig) => {\r\n const { apiEndpoint, env, path, fileNameTransform, appendUUID } = 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 // 파일명에 UUID 추가하는 함수\r\n const appendUUIDToFileName = (filename: string): string => {\r\n const lastDotIndex = filename.lastIndexOf(\".\");\r\n if (lastDotIndex === -1) {\r\n // 확장자가 없는 경우\r\n return `${filename}_${generateUUID()}`;\r\n }\r\n const name = filename.substring(0, lastDotIndex);\r\n const ext = filename.substring(lastDotIndex);\r\n return `${name}_${generateUUID()}${ext}`;\r\n };\r\n\r\n // 계층 구조 파일명 생성 함수\r\n const generateHierarchicalFileName = (file: File): string => {\r\n let filename = file.name;\r\n\r\n // 1. 사용자 정의 파일명 변환 콜백 적용\r\n if (fileNameTransform) {\r\n filename = fileNameTransform(filename, file);\r\n }\r\n\r\n // 2. UUID 자동 추가 (appendUUID가 true인 경우)\r\n if (appendUUID) {\r\n filename = appendUUIDToFileName(filename);\r\n }\r\n\r\n // {env}/{path}/{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":["import_react","editor","BlockSideMenu"]}
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, useRef } from "react";
5
5
  import {
6
6
  useCreateBlockNote,
7
7
  SideMenu as BlockSideMenu,
@@ -17,6 +17,86 @@ function cn(...inputs) {
17
17
  return inputs.filter(Boolean).join(" ");
18
18
  }
19
19
 
20
+ // src/utils/s3-uploader.ts
21
+ var generateUUID = () => {
22
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
23
+ return crypto.randomUUID();
24
+ }
25
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
26
+ const r = Math.random() * 16 | 0;
27
+ const v = c === "x" ? r : r & 3 | 8;
28
+ return v.toString(16);
29
+ });
30
+ };
31
+ var createS3Uploader = (config) => {
32
+ const { apiEndpoint, env, path, fileNameTransform, appendUUID } = config;
33
+ if (!apiEndpoint || apiEndpoint.trim() === "") {
34
+ throw new Error(
35
+ "apiEndpoint is required for S3 upload. Please provide a valid API endpoint."
36
+ );
37
+ }
38
+ if (!env) {
39
+ throw new Error("env is required. Must be 'development' or 'production'.");
40
+ }
41
+ if (!path || path.trim() === "") {
42
+ throw new Error("path is required and cannot be empty.");
43
+ }
44
+ const appendUUIDToFileName = (filename) => {
45
+ const lastDotIndex = filename.lastIndexOf(".");
46
+ if (lastDotIndex === -1) {
47
+ return `${filename}_${generateUUID()}`;
48
+ }
49
+ const name = filename.substring(0, lastDotIndex);
50
+ const ext = filename.substring(lastDotIndex);
51
+ return `${name}_${generateUUID()}${ext}`;
52
+ };
53
+ const generateHierarchicalFileName = (file) => {
54
+ let filename = file.name;
55
+ if (fileNameTransform) {
56
+ filename = fileNameTransform(filename, file);
57
+ }
58
+ if (appendUUID) {
59
+ filename = appendUUIDToFileName(filename);
60
+ }
61
+ return `${env}/${path}/${filename}`;
62
+ };
63
+ return async (file) => {
64
+ try {
65
+ if (!apiEndpoint || apiEndpoint.trim() === "") {
66
+ throw new Error(
67
+ "Invalid apiEndpoint: Cannot upload file without a valid API ENDPOINT"
68
+ );
69
+ }
70
+ const fileName = generateHierarchicalFileName(file);
71
+ const response = await fetch(
72
+ `${apiEndpoint}?key=${encodeURIComponent(fileName)}`
73
+ );
74
+ if (!response.ok) {
75
+ const errorText = await response.text() || "";
76
+ throw new Error(
77
+ `Failed to get presigned URL: ${response.statusText}, ${errorText}`
78
+ );
79
+ }
80
+ const responseData = await response.json();
81
+ const { presignedUrl, publicUrl } = responseData;
82
+ const uploadResponse = await fetch(presignedUrl, {
83
+ method: "PUT",
84
+ headers: {
85
+ "Content-Type": file.type || "application/octet-stream"
86
+ },
87
+ body: file
88
+ });
89
+ if (!uploadResponse.ok) {
90
+ throw new Error(`Failed to upload file: ${uploadResponse.statusText}`);
91
+ }
92
+ return publicUrl;
93
+ } catch (error) {
94
+ console.error("S3 upload failed:", error);
95
+ throw error;
96
+ }
97
+ };
98
+ };
99
+
20
100
  // src/components/LumirEditor.tsx
21
101
  import { jsx, jsxs } from "react/jsx-runtime";
22
102
  var ContentUtils = class {
@@ -137,32 +217,21 @@ var EditorConfig = class {
137
217
  return Array.from(set);
138
218
  }
139
219
  };
140
- var createObjectUrlUploader = async (file) => {
141
- return URL.createObjectURL(file);
142
- };
143
220
  var isImageFile = (file) => {
144
221
  return file.size > 0 && (file.type?.startsWith("image/") || !file.type && /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(file.name || ""));
145
222
  };
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
223
  function LumirEditor({
153
224
  // editor options
154
225
  initialContent,
155
226
  initialEmptyBlocks = 3,
156
227
  uploadFile,
228
+ s3Upload,
157
229
  tables,
158
230
  heading,
159
- animations = true,
160
231
  defaultStyles = true,
161
232
  disableExtensions,
162
233
  tabBehavior = "prefer-navigate-ui",
163
234
  trailingBlock = true,
164
- resolveFileUrl,
165
- storeImagesAsBase64 = true,
166
235
  allowVideoUpload = false,
167
236
  allowAudioUpload = false,
168
237
  allowFileUpload = false,
@@ -172,7 +241,6 @@ function LumirEditor({
172
241
  formattingToolbar = true,
173
242
  linkToolbar = true,
174
243
  sideMenu = true,
175
- slashMenu = true,
176
244
  emojiPicker = true,
177
245
  filePanel = true,
178
246
  tableHandles = true,
@@ -182,6 +250,7 @@ function LumirEditor({
182
250
  // callbacks / refs
183
251
  onContentChange
184
252
  }) {
253
+ const [isUploading, setIsUploading] = useState(false);
185
254
  const validatedContent = useMemo(() => {
186
255
  return ContentUtils.validateContent(initialContent, initialEmptyBlocks);
187
256
  }, [initialContent, initialEmptyBlocks]);
@@ -204,33 +273,60 @@ function LumirEditor({
204
273
  allowFileUpload
205
274
  );
206
275
  }, [disableExtensions, allowVideoUpload, allowAudioUpload, allowFileUpload]);
276
+ const fileNameTransformRef = useRef(s3Upload?.fileNameTransform);
277
+ useEffect(() => {
278
+ fileNameTransformRef.current = s3Upload?.fileNameTransform;
279
+ }, [s3Upload?.fileNameTransform]);
280
+ const memoizedS3Upload = useMemo(() => {
281
+ if (!s3Upload) return void 0;
282
+ return {
283
+ apiEndpoint: s3Upload.apiEndpoint,
284
+ env: s3Upload.env,
285
+ path: s3Upload.path,
286
+ appendUUID: s3Upload.appendUUID,
287
+ // 최신 콜백을 항상 사용하도록 ref를 통해 접근
288
+ fileNameTransform: (originalName, file) => {
289
+ return fileNameTransformRef.current ? fileNameTransformRef.current(originalName, file) : originalName;
290
+ }
291
+ };
292
+ }, [
293
+ s3Upload?.apiEndpoint,
294
+ s3Upload?.env,
295
+ s3Upload?.path,
296
+ s3Upload?.appendUUID
297
+ ]);
207
298
  const editor = useCreateBlockNote(
208
299
  {
209
300
  initialContent: validatedContent,
210
301
  tables: tableConfig,
211
302
  heading: headingConfig,
212
- animations,
303
+ animations: false,
304
+ // 기본적으로 애니메이션 비활성화
213
305
  defaultStyles,
214
306
  // 확장 비활성: 비디오/오디오/파일 제어
215
307
  disableExtensions: disabledExtensions,
216
308
  tabBehavior,
217
309
  trailingBlock,
218
- resolveFileUrl,
219
310
  uploadFile: async (file) => {
220
311
  if (!isImageFile(file)) {
221
312
  throw new Error("Only image files are allowed");
222
313
  }
223
- const custom = uploadFile;
224
- const fallback = storeImagesAsBase64 ? fileToBase64 : createObjectUrlUploader;
225
314
  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");
315
+ let imageUrl;
316
+ if (uploadFile) {
317
+ imageUrl = await uploadFile(file);
318
+ } else if (memoizedS3Upload?.apiEndpoint) {
319
+ const s3Uploader = createS3Uploader(memoizedS3Upload);
320
+ imageUrl = await s3Uploader(file);
321
+ } else {
322
+ throw new Error("No upload method available");
233
323
  }
324
+ return imageUrl;
325
+ } catch (error) {
326
+ console.error("Image upload failed:", error);
327
+ throw new Error(
328
+ "Upload failed: " + (error instanceof Error ? error.message : String(error))
329
+ );
234
330
  }
235
331
  },
236
332
  pasteHandler: (ctx) => {
@@ -247,17 +343,22 @@ function LumirEditor({
247
343
  }
248
344
  event.preventDefault();
249
345
  (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
- );
346
+ setIsUploading(true);
347
+ try {
348
+ for (const file of acceptedFiles) {
349
+ try {
350
+ const url = await editor2.uploadFile(file);
351
+ editor2.pasteHTML(`<img src="${url}" alt="image" />`);
352
+ } catch (err) {
353
+ console.warn(
354
+ "Image upload failed, skipped:",
355
+ file.name || "",
356
+ err
357
+ );
358
+ }
260
359
  }
360
+ } finally {
361
+ setIsUploading(false);
261
362
  }
262
363
  })();
263
364
  return true;
@@ -267,14 +368,12 @@ function LumirEditor({
267
368
  validatedContent,
268
369
  tableConfig,
269
370
  headingConfig,
270
- animations,
271
371
  defaultStyles,
272
372
  disabledExtensions,
273
373
  tabBehavior,
274
374
  trailingBlock,
275
- resolveFileUrl,
276
375
  uploadFile,
277
- storeImagesAsBase64
376
+ memoizedS3Upload
278
377
  ]
279
378
  );
280
379
  useEffect(() => {
@@ -312,17 +411,26 @@ function LumirEditor({
312
411
  const acceptedFiles = files.filter(isImageFile);
313
412
  if (acceptedFiles.length === 0) return;
314
413
  (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" />`);
414
+ setIsUploading(true);
415
+ try {
416
+ for (const file of acceptedFiles) {
417
+ try {
418
+ if (editor?.uploadFile) {
419
+ const url = await editor.uploadFile(file);
420
+ if (url) {
421
+ editor.pasteHTML(`<img src="${url}" alt="image" />`);
422
+ }
321
423
  }
424
+ } catch (err) {
425
+ console.warn(
426
+ "Image upload failed, skipped:",
427
+ file.name || "",
428
+ err
429
+ );
322
430
  }
323
- } catch (err) {
324
- console.warn("Image upload failed, skipped:", file.name || "", err);
325
431
  }
432
+ } finally {
433
+ setIsUploading(false);
326
434
  }
327
435
  })();
328
436
  };
@@ -341,57 +449,68 @@ function LumirEditor({
341
449
  const DragHandleOnlySideMenu = useMemo(() => {
342
450
  return (props) => /* @__PURE__ */ jsx(BlockSideMenu, { ...props, children: /* @__PURE__ */ jsx(DragHandleButton, { ...props }) });
343
451
  }, []);
344
- return /* @__PURE__ */ jsx("div", { className: cn("lumirEditor", className), children: /* @__PURE__ */ jsxs(
345
- BlockNoteView,
452
+ return /* @__PURE__ */ jsxs(
453
+ "div",
346
454
  {
347
- editor,
348
- editable,
349
- theme,
350
- formattingToolbar,
351
- linkToolbar,
352
- sideMenu: computedSideMenu,
353
- slashMenu: false,
354
- emojiPicker,
355
- filePanel,
356
- tableHandles,
357
- onSelectionChange,
455
+ className: cn("lumirEditor", className),
456
+ style: { position: "relative" },
358
457
  children: [
359
- slashMenu && /* @__PURE__ */ jsx(
360
- SuggestionMenuController,
458
+ /* @__PURE__ */ jsxs(
459
+ BlockNoteView,
361
460
  {
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)
461
+ editor,
462
+ editable,
463
+ theme,
464
+ formattingToolbar,
465
+ linkToolbar,
466
+ sideMenu: computedSideMenu,
467
+ slashMenu: false,
468
+ emojiPicker,
469
+ filePanel,
470
+ tableHandles,
471
+ onSelectionChange,
472
+ children: [
473
+ /* @__PURE__ */ jsx(
474
+ SuggestionMenuController,
475
+ {
476
+ triggerCharacter: "/",
477
+ getItems: useCallback(
478
+ async (query) => {
479
+ const items = getDefaultReactSlashMenuItems(editor);
480
+ const filtered = items.filter((item) => {
481
+ const key = (item?.key || "").toString().toLowerCase();
482
+ const title = (item?.title || "").toString().toLowerCase();
483
+ if (["video", "audio", "file"].includes(key)) return false;
484
+ if (title.includes("video") || title.includes("audio") || title.includes("file"))
485
+ return false;
486
+ return true;
487
+ });
488
+ if (!query) return filtered;
489
+ const q = query.toLowerCase();
490
+ return filtered.filter(
491
+ (item) => item.title?.toLowerCase().includes(q) || (item.aliases || []).some(
492
+ (a) => a.toLowerCase().includes(q)
493
+ )
494
+ );
495
+ },
496
+ [editor]
379
497
  )
380
- );
381
- },
382
- [editor]
383
- )
498
+ }
499
+ ),
500
+ !sideMenuAddButton && /* @__PURE__ */ jsx(SideMenuController, { sideMenu: DragHandleOnlySideMenu })
501
+ ]
384
502
  }
385
503
  ),
386
- !sideMenuAddButton && /* @__PURE__ */ jsx(SideMenuController, { sideMenu: DragHandleOnlySideMenu })
504
+ isUploading && /* @__PURE__ */ jsx("div", { className: "lumirEditor-upload-overlay", children: /* @__PURE__ */ jsx("div", { className: "lumirEditor-spinner" }) })
387
505
  ]
388
506
  }
389
- ) });
507
+ );
390
508
  }
391
509
  export {
392
510
  ContentUtils,
393
511
  EditorConfig,
394
512
  LumirEditor,
395
- cn
513
+ cn,
514
+ createS3Uploader
396
515
  };
397
516
  //# sourceMappingURL=index.mjs.map