게시글 작성 화면을 구현하려면, WYSIWYG 에디터를 사용하게 된다. ChatGPT의 추천으로 나는 TinyMCE라는 에디터를 선택했다. 이 에디터를 이용해 게시판 작성 화면을 구현하면서, 이미지와 파일을 다루는 방법에 대해 내가 작업한 내용을 이 글에서 소개한다. 나도 대부분의 코드 작성을 claude-3.5-sonnet(cursor AI)이라는 친구에게 맡기고, 이후 생긴 버그들을 디버깅한 정도라, 코드에는 개선의 여지가 더 많을 수 있다.
1. 사진/파일 업로드 하기
문제상황


TinyMCE의 이미지업로더(ImageUploadPicker)와 파일업로더(FileUploadPicker) 기능을 활용하고자 한다면, 이미지/파일을 어딘가에 업로드하고, 그 업로드된 URL을 반환해주는 핸들러 함수가 필요하다.
해결방법
나는 아래의 코드로 위 목적을 달성했다. 더보기 버튼을 누르면 펼쳐진다. 설명하자면 다음과 같다.
1. firestore/storage에 이미지와 파일을 저장하도록 했다.
2. 이미지의 캐시 주기를 1년으로 했다(목적이 있어서 매우 길게 설정했다).
3. plugins와 toolbar에 image, file 관련 필요한 것들을 추가해준다.
4. 이미지는 images_upload_handler, 파일은 file_picker_callback 인자를 통해 tinymce-react.Editor에게 전달해주었다.
import { Editor } from '@tinymce/tinymce-react';
import { getStorage, uploadBytes, getDownloadURL } from 'firebase/storage';
...
function RichTextEditor({ value = '', onChange }: RichTextEditorProps) {
const handleImageUpload = async (blobInfo: any) => {
const storage = getStorage();
const fileName = `images/${Date.now()}-${blobInfo.filename()}`;
const storageRef = ref(storage, fileName);
const metadata = {
cacheControl: 'public,max-age=31536000' // cache-control 헤더의 캐싱 주기를 1년으로 설정함
};
try {
const snapshot = await uploadBytes(storageRef, blobInfo.blob(), metadata);
const url = await getDownloadURL(snapshot.ref);
return url;
} catch (error) {
console.error('이미지 업로드 실패:', error);
throw error;
}
};
const handleFileUpload = async (file: File) => {
const storage = getStorage();
const fileName = `files/${Date.now()}-${file.name}`;
const storageRef = ref(storage, fileName);
try {
const snapshot = await uploadBytes(storageRef, file);
return await getDownloadURL(snapshot.ref);
} catch (error) {
console.error('파일 업로드 실패:', error);
throw error;
}
};
...
return (
<Editor
...
init={{
...
plugins: [
..., 'image', 'media'
],
toolbar: ... + 'image | link | file',
images_upload_handler: handleImageUpload, // 이미지 핸들링 함수 추가
file_picker_types: 'file image media',
file_browser_callback_types: 'file',
automatic_uploads: true,
file_picker_callback: function(callback, _, meta) {
const input = document.createElement('input');
input.setAttribute('type', 'file');
if (meta.filetype === 'image') {
input.setAttribute('accept', 'image/*');
}
input.onchange = async function() {
const file = (input.files as FileList)[0];
try {
const url = await handleFileUpload(file); // 파일 핸들링 함수 추가
console.log('파일 업로드 완료:', {
fileName: file.name,
fileUrl: url,
fileType: meta.filetype
});
callback(url, { text: file.name });
} catch (error) {
console.error('파일 업로드 실패:', error);
}
};
input.click();
},
}}
/>);
}
2. 파일 업로드 시, 기본 <a> 태그 대신 커스텀 태그로 대체하기
문제상황
파일을 글 안에 첨부하고 싶을 수 있다. 하지만, TinyMCE는 파란글자에 밑줄로 파일이름만 보여주고, 티스토리 처럼 아래의 이쁜 요소로 보여주지 않는다.

해결방법
에디터의 내용이 수정될때마다 호출되는 함수인 onEditorChange에서, FileUploadPicker의 소행으로 인해 삽입된 a태그를 인식하여, 내가 원하는 DOM 요소로 교체하도록 했다.
아래의 코드와 주석을 참고할 수 있다. 더보기 버튼을 누르면 펼쳐진다.
1. extractNewFiles 함수는 수정된 컨텐츠와, 수정 이전 컨텐츠를 비교하여 새로운 url이 추가되었는지를 검사한다. 그리고 isFilePickerUrl 함수를 이용하여, 그 URL이 FileUploadPicker의 동작으로 추가된 URL인지를 검사한다. 이 때, URL만 반환하지 않고, FileInfo라는 인터페이스로 변환해서 반한해주었다.
2. isFilePickerUrl 함수는 앞서 설명했듯, URL이 FileUploadPicker의 동작으로 추가된 URL인지를 검사한다.
3. escapeRegExp는 특수문자를 다루는 것을 돕는 함수이다. onEditorChange 내부에서 사용하는 곳을 보면 href 안의 주소를 인자로 사용하고 있다. 주소에는 ?, . 등 특수문자들이 있는데 백슬래시(\)를 붙여, 특수문자가 특수한 의미로 해석되지 않고 그냥 문자 그 자체로 인식되도록 한다.
4. 앞서 설명한 도구 함수들을 이용해서 onEditorChange 함수를 구현한다. 대체해야될 태그를 발견하면 그 안의 URL과 title 정보만 추출하여, 새로운 커스텀 태그로 재구성 하여 삽입한다.
interface FileInfo {
url: string;
title: string;
}
// 필요한 도구 함수 1
const extractNewFiles = (currentContent: string, prevContent: string): FileInfo[] => {
// 파일 업로더를 통해 추가된 링크 패턴 찾기
const fileUploaderPattern = /<a[^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/g;
const currentMatches = Array.from(currentContent.matchAll(fileUploaderPattern));
const prevMatches = Array.from(prevContent.matchAll(fileUploaderPattern));
const currentFiles = currentMatches
.map(match => ({
url: match[1],
title: match[2]
}))
.filter(file => isFilePickerUrl(file.url));
const prevUrls = new Set(
prevMatches
.map(match => match[1])
.filter(url => isFilePickerUrl(url))
);
return currentFiles.filter(file => !prevUrls.has(file.url));
};
// 필요한 도구 함수 2
const isFilePickerUrl = (url: string): boolean => {
// file_picker에서 추가된 URL인지 확인하는 로직
return url.includes('o/files') && url.startsWith('https://firebasestorage.googleapis.com');
};
// 필요한 도구 함수 3
const escapeRegExp = (string: string): string => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
// Editor에 Props로 넘겨줌
function RichTextEditor({ value = '', onChange }: RichTextEditorProps) {
return (
<Editor
onEditorChange={(content, editor) => {
const currentContent = editor.getContent();
const prevContent = value || '';
// 새로운 파일이 추가되었는지 확인
const newFiles = extractNewFiles(currentContent, prevContent);
if (newFiles.length > 0) {
console.log('새로운 파일 링크가 추가됨:', newFiles);
let updatedContent = currentContent;
newFiles.forEach(({url, title}) => {
if (isFilePickerUrl(url)) {
const customElement = `<div class="custom-file-element"
style="
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 8px 16px;
margin: 8px 0;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
background-color: #f8f9fa;
max-width: fit-content;
"
contenteditable="false"
data-url="${url}"
onclick="window.open('${url}', '_blank')"
>
<span class="file-icon" style="font-size: 1.2em;">📎</span>
<span class="file-name" style="color: #2c3e50;">${title}</span>
</div><p></p>`;
const regex = new RegExp(`<p>\\s*<a[^>]*href="${escapeRegExp(url)}"[^>]*>.*?<\\/a>\\s*\`?\\d*\\s*<\\/p>`, 'g');
updatedContent = updatedContent.replace(regex, customElement);
}
});
editor.setContent(updatedContent);
onChange(updatedContent);
return;
}
onChange(content);
}}
/>
);
}
항상 이런 외부 라이브러리를 사용하는 것은 어렵다. 특히 위지윅 에디터를 사용할때면 문서를 꼼꼼히 읽고 골치아픈 디버깅을 매번 하게 되는 것 같다. 그런 누군가를 위해 내 위지윅 관련 코드 전체를 올리는 것으로 글을 마무리한다. 더보기 버튼을 누르면 펼쳐진다.
import { Editor } from '@tinymce/tinymce-react';
import { getStorage, ref, uploadBytes, getDownloadURL } from 'firebase/storage';
interface RichTextEditorProps {
value?: string;
onChange: (content: string) => void;
}
interface FileInfo {
url: string;
title: string;
}
const extractNewFiles = (currentContent: string, prevContent: string): FileInfo[] => {
// 파일 업로더를 통해 추가된 링크 패턴 찾기
const fileUploaderPattern = /<a[^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/g;
const currentMatches = Array.from(currentContent.matchAll(fileUploaderPattern));
const prevMatches = Array.from(prevContent.matchAll(fileUploaderPattern));
const currentFiles = currentMatches
.map(match => ({
url: match[1],
title: match[2]
}))
.filter(file => isFilePickerUrl(file.url));
const prevUrls = new Set(
prevMatches
.map(match => match[1])
.filter(url => isFilePickerUrl(url))
);
return currentFiles.filter(file => !prevUrls.has(file.url));
};
const isFilePickerUrl = (url: string): boolean => {
// file_picker에서 추가된 URL인지 확인하는 로직
return url.includes('o/files') && url.startsWith('https://firebasestorage.googleapis.com');
};
const escapeRegExp = (string: string): string => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
function RichTextEditor({ value = '', onChange }: RichTextEditorProps) {
const handleImageUpload = async (blobInfo: any) => {
const storage = getStorage();
const fileName = `images/${Date.now()}-${blobInfo.filename()}`;
const storageRef = ref(storage, fileName);
const metadata = {
cacheControl: 'public,max-age=31536000' // 1년
};
try {
const snapshot = await uploadBytes(storageRef, blobInfo.blob(), metadata);
const url = await getDownloadURL(snapshot.ref);
return url;
} catch (error) {
console.error('이미지 업로드 실패:', error);
throw error;
}
};
const handleFileUpload = async (file: File) => {
const storage = getStorage();
const fileName = `files/${Date.now()}-${file.name}`;
const storageRef = ref(storage, fileName);
try {
const snapshot = await uploadBytes(storageRef, file);
return await getDownloadURL(snapshot.ref);
} catch (error) {
console.error('파일 업로드 실패:', error);
throw error;
}
};
return (
<Editor
apiKey={import.meta.env.VITE_TINYMCE_API_KEY}
value={value}
init={{
height: 500,
menubar: true,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'help', 'wordcount'
],
extended_valid_elements: 'span[*],div[*|onclick|style|class|contenteditable|data-url]',
toolbar: 'undo redo | formatselect | ' +
'bold italic backcolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | image | link | file',
images_upload_handler: handleImageUpload,
file_picker_types: 'file image media',
file_browser_callback_types: 'file',
automatic_uploads: true,
file_picker_callback: function(callback, _, meta) {
const input = document.createElement('input');
input.setAttribute('type', 'file');
if (meta.filetype === 'image') {
input.setAttribute('accept', 'image/*');
}
input.onchange = async function() {
const file = (input.files as FileList)[0];
try {
const url = await handleFileUpload(file);
console.log('파일 업로드 완료:', {
fileName: file.name,
fileUrl: url,
fileType: meta.filetype
});
callback(url, { text: file.name });
} catch (error) {
console.error('파일 업로드 실패:', error);
}
};
input.click();
},
}}
onEditorChange={(content, editor) => {
const currentContent = editor.getContent();
const prevContent = value || '';
// 새로운 파일이 추가되었는지 확인
const newFiles = extractNewFiles(currentContent, prevContent);
if (newFiles.length > 0) {
console.log('새로운 파일 링크가 추가됨:', newFiles);
let updatedContent = currentContent;
newFiles.forEach(({url, title}) => {
if (isFilePickerUrl(url)) {
const customElement = `<div class="custom-file-element"
style="
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 8px 16px;
margin: 8px 0;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
background-color: #f8f9fa;
max-width: fit-content;
"
contenteditable="false"
data-url="${url}"
onclick="window.open('${url}', '_blank')"
>
<span class="file-icon" style="font-size: 1.2em;">📎</span>
<span class="file-name" style="color: #2c3e50;">${title}</span>
</div><p></p>`;
const regex = new RegExp(`<p>\\s*<a[^>]*href="${escapeRegExp(url)}"[^>]*>.*?<\\/a>\\s*\`?\\d*\\s*<\\/p>`, 'g');
updatedContent = updatedContent.replace(regex, customElement);
}
});
editor.setContent(updatedContent);
onChange(updatedContent);
return;
}
onChange(content);
}}
onInit={() => {
console.log('Editor is ready');
if (import.meta.env.VITE_TINYMCE_API_KEY) {
console.log('API Key is set');
} else {
console.log('API Key is not set');
}
}}
/>
);
}
export default RichTextEditor;
'개발이야기 > 토막글' 카테고리의 다른 글
Github CLI로 터미널에서 git 인증 편하게 하기 (0) | 2025.03.13 |
---|---|
안드로이드 기기와 맥북, 와이파이 환경에서 무선연결하기 (1) | 2024.12.19 |
Kubernetes 사용자라면 설치해야 할 보조 도구 모음 (1) | 2024.01.06 |
mysqldump 치트 시트 (0) | 2024.01.06 |
내 포트를 잡아먹고 어디선가 돌아가고 있는 내 로컬 서비스 죽이기 (2) | 2023.09.23 |
게시글 작성 화면을 구현하려면, WYSIWYG 에디터를 사용하게 된다. ChatGPT의 추천으로 나는 TinyMCE라는 에디터를 선택했다. 이 에디터를 이용해 게시판 작성 화면을 구현하면서, 이미지와 파일을 다루는 방법에 대해 내가 작업한 내용을 이 글에서 소개한다. 나도 대부분의 코드 작성을 claude-3.5-sonnet(cursor AI)이라는 친구에게 맡기고, 이후 생긴 버그들을 디버깅한 정도라, 코드에는 개선의 여지가 더 많을 수 있다.
1. 사진/파일 업로드 하기
문제상황


TinyMCE의 이미지업로더(ImageUploadPicker)와 파일업로더(FileUploadPicker) 기능을 활용하고자 한다면, 이미지/파일을 어딘가에 업로드하고, 그 업로드된 URL을 반환해주는 핸들러 함수가 필요하다.
해결방법
나는 아래의 코드로 위 목적을 달성했다. 더보기 버튼을 누르면 펼쳐진다. 설명하자면 다음과 같다.
1. firestore/storage에 이미지와 파일을 저장하도록 했다.
2. 이미지의 캐시 주기를 1년으로 했다(목적이 있어서 매우 길게 설정했다).
3. plugins와 toolbar에 image, file 관련 필요한 것들을 추가해준다.
4. 이미지는 images_upload_handler, 파일은 file_picker_callback 인자를 통해 tinymce-react.Editor에게 전달해주었다.
import { Editor } from '@tinymce/tinymce-react';
import { getStorage, uploadBytes, getDownloadURL } from 'firebase/storage';
...
function RichTextEditor({ value = '', onChange }: RichTextEditorProps) {
const handleImageUpload = async (blobInfo: any) => {
const storage = getStorage();
const fileName = `images/${Date.now()}-${blobInfo.filename()}`;
const storageRef = ref(storage, fileName);
const metadata = {
cacheControl: 'public,max-age=31536000' // cache-control 헤더의 캐싱 주기를 1년으로 설정함
};
try {
const snapshot = await uploadBytes(storageRef, blobInfo.blob(), metadata);
const url = await getDownloadURL(snapshot.ref);
return url;
} catch (error) {
console.error('이미지 업로드 실패:', error);
throw error;
}
};
const handleFileUpload = async (file: File) => {
const storage = getStorage();
const fileName = `files/${Date.now()}-${file.name}`;
const storageRef = ref(storage, fileName);
try {
const snapshot = await uploadBytes(storageRef, file);
return await getDownloadURL(snapshot.ref);
} catch (error) {
console.error('파일 업로드 실패:', error);
throw error;
}
};
...
return (
<Editor
...
init={{
...
plugins: [
..., 'image', 'media'
],
toolbar: ... + 'image | link | file',
images_upload_handler: handleImageUpload, // 이미지 핸들링 함수 추가
file_picker_types: 'file image media',
file_browser_callback_types: 'file',
automatic_uploads: true,
file_picker_callback: function(callback, _, meta) {
const input = document.createElement('input');
input.setAttribute('type', 'file');
if (meta.filetype === 'image') {
input.setAttribute('accept', 'image/*');
}
input.onchange = async function() {
const file = (input.files as FileList)[0];
try {
const url = await handleFileUpload(file); // 파일 핸들링 함수 추가
console.log('파일 업로드 완료:', {
fileName: file.name,
fileUrl: url,
fileType: meta.filetype
});
callback(url, { text: file.name });
} catch (error) {
console.error('파일 업로드 실패:', error);
}
};
input.click();
},
}}
/>);
}
2. 파일 업로드 시, 기본 <a> 태그 대신 커스텀 태그로 대체하기
문제상황
파일을 글 안에 첨부하고 싶을 수 있다. 하지만, TinyMCE는 파란글자에 밑줄로 파일이름만 보여주고, 티스토리 처럼 아래의 이쁜 요소로 보여주지 않는다.

해결방법
에디터의 내용이 수정될때마다 호출되는 함수인 onEditorChange에서, FileUploadPicker의 소행으로 인해 삽입된 a태그를 인식하여, 내가 원하는 DOM 요소로 교체하도록 했다.
아래의 코드와 주석을 참고할 수 있다. 더보기 버튼을 누르면 펼쳐진다.
1. extractNewFiles 함수는 수정된 컨텐츠와, 수정 이전 컨텐츠를 비교하여 새로운 url이 추가되었는지를 검사한다. 그리고 isFilePickerUrl 함수를 이용하여, 그 URL이 FileUploadPicker의 동작으로 추가된 URL인지를 검사한다. 이 때, URL만 반환하지 않고, FileInfo라는 인터페이스로 변환해서 반한해주었다.
2. isFilePickerUrl 함수는 앞서 설명했듯, URL이 FileUploadPicker의 동작으로 추가된 URL인지를 검사한다.
3. escapeRegExp는 특수문자를 다루는 것을 돕는 함수이다. onEditorChange 내부에서 사용하는 곳을 보면 href 안의 주소를 인자로 사용하고 있다. 주소에는 ?, . 등 특수문자들이 있는데 백슬래시(\)를 붙여, 특수문자가 특수한 의미로 해석되지 않고 그냥 문자 그 자체로 인식되도록 한다.
4. 앞서 설명한 도구 함수들을 이용해서 onEditorChange 함수를 구현한다. 대체해야될 태그를 발견하면 그 안의 URL과 title 정보만 추출하여, 새로운 커스텀 태그로 재구성 하여 삽입한다.
interface FileInfo {
url: string;
title: string;
}
// 필요한 도구 함수 1
const extractNewFiles = (currentContent: string, prevContent: string): FileInfo[] => {
// 파일 업로더를 통해 추가된 링크 패턴 찾기
const fileUploaderPattern = /<a[^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/g;
const currentMatches = Array.from(currentContent.matchAll(fileUploaderPattern));
const prevMatches = Array.from(prevContent.matchAll(fileUploaderPattern));
const currentFiles = currentMatches
.map(match => ({
url: match[1],
title: match[2]
}))
.filter(file => isFilePickerUrl(file.url));
const prevUrls = new Set(
prevMatches
.map(match => match[1])
.filter(url => isFilePickerUrl(url))
);
return currentFiles.filter(file => !prevUrls.has(file.url));
};
// 필요한 도구 함수 2
const isFilePickerUrl = (url: string): boolean => {
// file_picker에서 추가된 URL인지 확인하는 로직
return url.includes('o/files') && url.startsWith('https://firebasestorage.googleapis.com');
};
// 필요한 도구 함수 3
const escapeRegExp = (string: string): string => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
// Editor에 Props로 넘겨줌
function RichTextEditor({ value = '', onChange }: RichTextEditorProps) {
return (
<Editor
onEditorChange={(content, editor) => {
const currentContent = editor.getContent();
const prevContent = value || '';
// 새로운 파일이 추가되었는지 확인
const newFiles = extractNewFiles(currentContent, prevContent);
if (newFiles.length > 0) {
console.log('새로운 파일 링크가 추가됨:', newFiles);
let updatedContent = currentContent;
newFiles.forEach(({url, title}) => {
if (isFilePickerUrl(url)) {
const customElement = `<div class="custom-file-element"
style="
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 8px 16px;
margin: 8px 0;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
background-color: #f8f9fa;
max-width: fit-content;
"
contenteditable="false"
data-url="${url}"
onclick="window.open('${url}', '_blank')"
>
<span class="file-icon" style="font-size: 1.2em;">📎</span>
<span class="file-name" style="color: #2c3e50;">${title}</span>
</div><p></p>`;
const regex = new RegExp(`<p>\\s*<a[^>]*href="${escapeRegExp(url)}"[^>]*>.*?<\\/a>\\s*\`?\\d*\\s*<\\/p>`, 'g');
updatedContent = updatedContent.replace(regex, customElement);
}
});
editor.setContent(updatedContent);
onChange(updatedContent);
return;
}
onChange(content);
}}
/>
);
}
항상 이런 외부 라이브러리를 사용하는 것은 어렵다. 특히 위지윅 에디터를 사용할때면 문서를 꼼꼼히 읽고 골치아픈 디버깅을 매번 하게 되는 것 같다. 그런 누군가를 위해 내 위지윅 관련 코드 전체를 올리는 것으로 글을 마무리한다. 더보기 버튼을 누르면 펼쳐진다.
import { Editor } from '@tinymce/tinymce-react';
import { getStorage, ref, uploadBytes, getDownloadURL } from 'firebase/storage';
interface RichTextEditorProps {
value?: string;
onChange: (content: string) => void;
}
interface FileInfo {
url: string;
title: string;
}
const extractNewFiles = (currentContent: string, prevContent: string): FileInfo[] => {
// 파일 업로더를 통해 추가된 링크 패턴 찾기
const fileUploaderPattern = /<a[^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/g;
const currentMatches = Array.from(currentContent.matchAll(fileUploaderPattern));
const prevMatches = Array.from(prevContent.matchAll(fileUploaderPattern));
const currentFiles = currentMatches
.map(match => ({
url: match[1],
title: match[2]
}))
.filter(file => isFilePickerUrl(file.url));
const prevUrls = new Set(
prevMatches
.map(match => match[1])
.filter(url => isFilePickerUrl(url))
);
return currentFiles.filter(file => !prevUrls.has(file.url));
};
const isFilePickerUrl = (url: string): boolean => {
// file_picker에서 추가된 URL인지 확인하는 로직
return url.includes('o/files') && url.startsWith('https://firebasestorage.googleapis.com');
};
const escapeRegExp = (string: string): string => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
function RichTextEditor({ value = '', onChange }: RichTextEditorProps) {
const handleImageUpload = async (blobInfo: any) => {
const storage = getStorage();
const fileName = `images/${Date.now()}-${blobInfo.filename()}`;
const storageRef = ref(storage, fileName);
const metadata = {
cacheControl: 'public,max-age=31536000' // 1년
};
try {
const snapshot = await uploadBytes(storageRef, blobInfo.blob(), metadata);
const url = await getDownloadURL(snapshot.ref);
return url;
} catch (error) {
console.error('이미지 업로드 실패:', error);
throw error;
}
};
const handleFileUpload = async (file: File) => {
const storage = getStorage();
const fileName = `files/${Date.now()}-${file.name}`;
const storageRef = ref(storage, fileName);
try {
const snapshot = await uploadBytes(storageRef, file);
return await getDownloadURL(snapshot.ref);
} catch (error) {
console.error('파일 업로드 실패:', error);
throw error;
}
};
return (
<Editor
apiKey={import.meta.env.VITE_TINYMCE_API_KEY}
value={value}
init={{
height: 500,
menubar: true,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'help', 'wordcount'
],
extended_valid_elements: 'span[*],div[*|onclick|style|class|contenteditable|data-url]',
toolbar: 'undo redo | formatselect | ' +
'bold italic backcolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | image | link | file',
images_upload_handler: handleImageUpload,
file_picker_types: 'file image media',
file_browser_callback_types: 'file',
automatic_uploads: true,
file_picker_callback: function(callback, _, meta) {
const input = document.createElement('input');
input.setAttribute('type', 'file');
if (meta.filetype === 'image') {
input.setAttribute('accept', 'image/*');
}
input.onchange = async function() {
const file = (input.files as FileList)[0];
try {
const url = await handleFileUpload(file);
console.log('파일 업로드 완료:', {
fileName: file.name,
fileUrl: url,
fileType: meta.filetype
});
callback(url, { text: file.name });
} catch (error) {
console.error('파일 업로드 실패:', error);
}
};
input.click();
},
}}
onEditorChange={(content, editor) => {
const currentContent = editor.getContent();
const prevContent = value || '';
// 새로운 파일이 추가되었는지 확인
const newFiles = extractNewFiles(currentContent, prevContent);
if (newFiles.length > 0) {
console.log('새로운 파일 링크가 추가됨:', newFiles);
let updatedContent = currentContent;
newFiles.forEach(({url, title}) => {
if (isFilePickerUrl(url)) {
const customElement = `<div class="custom-file-element"
style="
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 8px 16px;
margin: 8px 0;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
background-color: #f8f9fa;
max-width: fit-content;
"
contenteditable="false"
data-url="${url}"
onclick="window.open('${url}', '_blank')"
>
<span class="file-icon" style="font-size: 1.2em;">📎</span>
<span class="file-name" style="color: #2c3e50;">${title}</span>
</div><p></p>`;
const regex = new RegExp(`<p>\\s*<a[^>]*href="${escapeRegExp(url)}"[^>]*>.*?<\\/a>\\s*\`?\\d*\\s*<\\/p>`, 'g');
updatedContent = updatedContent.replace(regex, customElement);
}
});
editor.setContent(updatedContent);
onChange(updatedContent);
return;
}
onChange(content);
}}
onInit={() => {
console.log('Editor is ready');
if (import.meta.env.VITE_TINYMCE_API_KEY) {
console.log('API Key is set');
} else {
console.log('API Key is not set');
}
}}
/>
);
}
export default RichTextEditor;
'개발이야기 > 토막글' 카테고리의 다른 글
Github CLI로 터미널에서 git 인증 편하게 하기 (0) | 2025.03.13 |
---|---|
안드로이드 기기와 맥북, 와이파이 환경에서 무선연결하기 (1) | 2024.12.19 |
Kubernetes 사용자라면 설치해야 할 보조 도구 모음 (1) | 2024.01.06 |
mysqldump 치트 시트 (0) | 2024.01.06 |
내 포트를 잡아먹고 어디선가 돌아가고 있는 내 로컬 서비스 죽이기 (2) | 2023.09.23 |