diff --git a/src/api/boardApi.js b/src/api/boardApi.js index ef0b725..bc68a89 100644 --- a/src/api/boardApi.js +++ b/src/api/boardApi.js @@ -26,6 +26,15 @@ export const boardApi = { remove(id) { return http.delete(`/board/posts/${id}`) }, + // 본문 인라인 이미지 업로드(서버 DB 저장) → { url } (예: /api/images/123) + uploadImage(blob) { + const fd = new FormData() + fd.append('image', blob, 'image.jpg') + return http.post('/board/images', fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 30000, + }) + }, tags() { return http.get('/board/tags') }, diff --git a/src/components/editor/MarkdownEditor.vue b/src/components/editor/MarkdownEditor.vue index e49f48a..7bf94e8 100644 --- a/src/components/editor/MarkdownEditor.vue +++ b/src/components/editor/MarkdownEditor.vue @@ -7,6 +7,7 @@ import 'prismjs/themes/prism-tomorrow.css' import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css' import MarkdownViewer from '@/components/editor/MarkdownViewer.vue' import { imageToBlob } from '@/utils/receiptOcr' +import { boardApi } from '@/api/boardApi' const props = defineProps({ modelValue: { type: String, default: '' }, @@ -104,32 +105,37 @@ function insertLink() { }) } -// 이미지: 파일 선택 → 리사이즈/압축(JPEG) → base64 임베드 (웹 에디터와 동일하게 본문에 포함, 별도 서버 저장 없음) +// 이미지: 파일 → 리사이즈/압축(JPEG) → 서버 업로드(DB 저장) → 본문엔 /api/images/{id} URL 만 삽입 const imgInput = ref(null) const imgBusy = ref(false) +const imgMsg = ref('') +let imgMsgTimer = null +function flashImgMsg(msg) { + imgMsg.value = msg + if (imgMsgTimer) clearTimeout(imgMsgTimer) + imgMsgTimer = setTimeout(() => (imgMsg.value = ''), 4000) +} function pickImage() { imgInput.value?.click() } -function blobToDataUrl(blob) { - return new Promise((resolve, reject) => { - const r = new FileReader() - r.onload = () => resolve(r.result) - r.onerror = reject - r.readAsDataURL(blob) - }) +// 압축 후 업로드 → URL 반환 (PC textarea·Toast UI 공용) +async function uploadCompressed(fileOrBlob) { + const blob = await imageToBlob(fileOrBlob, 1280) // 긴 변 1280px, JPEG 0.85 + const { url } = await boardApi.uploadImage(blob) + return url } async function onImagePick(e) { const file = e.target.files?.[0] e.target.value = '' if (!file) return imgBusy.value = true + imgMsg.value = '이미지 업로드 중…' try { - const blob = await imageToBlob(file, 1280) // 긴 변 1280px, JPEG 0.85 압축 - const dataUrl = await blobToDataUrl(blob) + const url = await uploadCompressed(file) const ta = taRef.value const s = ta ? ta.selectionStart : text.value.length const alt = file.name.replace(/\.[^.]+$/, '') - const md = `` + const md = `` text.value = text.value.slice(0, s) + md + text.value.slice(s) nextTick(() => { if (!ta) return @@ -137,8 +143,9 @@ async function onImagePick(e) { const pos = s + md.length ta.setSelectionRange(pos, pos) }) - } catch { - /* 이미지 처리 실패 무시 */ + imgMsg.value = '' + } catch (err) { + flashImgMsg(err?.response?.data?.message || '이미지 업로드에 실패했습니다.') } finally { imgBusy.value = false } @@ -167,6 +174,18 @@ onMounted(() => { emit('update:modelValue', lastEmitted) }, }, + hooks: { + // 이미지 추가 시 base64 대신 서버 업로드 → URL 삽입 + addImageBlobHook: async (blob, callback) => { + try { + const url = await uploadCompressed(blob) + callback(url, '') + } catch { + /* 업로드 실패 시 미삽입 */ + } + return false + }, + }, }) }) @@ -216,6 +235,7 @@ onBeforeUnmount(() => { +
{{ imgMsg }}