Files
sb-front/src/components/editor/MarkdownEditor.vue
T
ByungCheol f45014f827
CI / build (push) Failing after 11m37s
feat: PC 에디터 이미지를 첨부(썸네일)로 분리 — 본문 텍스트에 URL 미노출 + 썸네일 확대
- 이미지 마크다운(![](url))을 본문 textarea 에서 분리해 별도 첨부 목록으로 관리
  · 저장 시 본문 끝에 합쳐 emit, 편집 로드 시 본문/이미지 분리
  · 썸네일 120px 확대 + 제거(×) 버튼
- 텍스트는 깨끗하게(긴 URL 미노출), 이미지는 썸네일로 확인

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:57:00 +09:00

377 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import Editor from '@toast-ui/editor'
import '@toast-ui/editor/dist/toastui-editor.css'
import codeSyntaxHighlight from '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight-all'
import 'prismjs/themes/prism-tomorrow.css'
import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css'
import { imageToBlob } from '@/utils/receiptOcr'
import { boardApi } from '@/api/boardApi'
const props = defineProps({
modelValue: { type: String, default: '' },
height: { type: String, default: '400px' },
placeholder: { type: String, default: '' },
initialEditType: { type: String, default: 'wysiwyg' }, // 'markdown' | 'wysiwyg'
})
const emit = defineEmits(['update:modelValue'])
// PC(Electron)는 Toast UI 에디터가 Windows IME 와 충돌(첫 자음 중복)하므로 textarea 기반 에디터를 쓴다.
// 웹/모바일(APK)은 Toast UI 위지윅 유지.
const isElectron = typeof navigator !== 'undefined' && /electron/i.test(navigator.userAgent)
// 공용: 이미지 압축 후 서버 업로드 → URL 반환
async function uploadCompressed(fileOrBlob) {
const blob = await imageToBlob(fileOrBlob, 1280) // 긴 변 1280px, JPEG 0.85
const { url } = await boardApi.uploadImage(blob)
return url
}
/* ===================== PC: 본문 텍스트(textarea) + 이미지 첨부(썸네일) ===================== */
// 이미지 마크다운은 본문 텍스트에 노출하지 않고 별도 첨부로 관리 → 저장 시 본문 끝에 합쳐 emit.
const IMG_RE = /!\[([^\]]*)\]\(\s*([^)\s]+)[^)]*\)/g
function splitContent(md) {
const imgs = []
const body = (md || '')
.replace(IMG_RE, (_, alt, url) => {
imgs.push({ url, alt: alt || '' })
return ''
})
.replace(/\n{3,}/g, '\n\n')
.trim()
return { body, imgs }
}
const _init = splitContent(props.modelValue)
const bodyText = ref(_init.body)
const images = ref(_init.imgs) // [{ url, alt }]
const taRef = ref(null)
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 combine() {
const imgMd = images.value.map((i) => `![${i.alt || ''}](${i.url})`).join('\n\n')
return [bodyText.value.trim(), imgMd].filter(Boolean).join('\n\n')
}
let lastEmitted = isElectron ? combine() : (props.modelValue || '')
if (isElectron) {
watch(
[bodyText, images],
() => {
lastEmitted = combine()
emit('update:modelValue', lastEmitted)
},
{ deep: true },
)
watch(
() => props.modelValue,
(v) => {
if ((v || '') === lastEmitted) return
const sp = splitContent(v)
bodyText.value = sp.body
images.value = sp.imgs
lastEmitted = v || ''
},
)
}
// 선택 영역을 마커로 감싸기(굵게/기울임/코드 등)
function surround(before, after = before, placeholder = '') {
const ta = taRef.value
if (!ta) return
const s = ta.selectionStart
const e = ta.selectionEnd
const val = bodyText.value
const sel = val.slice(s, e) || placeholder
bodyText.value = val.slice(0, s) + before + sel + after + val.slice(e)
nextTick(() => {
ta.focus()
const start = s + before.length
ta.setSelectionRange(start, start + sel.length)
})
}
// 선택한 줄들 앞에 접두어 추가(제목/인용/목록 등)
function linePrefix(prefix) {
const ta = taRef.value
if (!ta) return
const s = ta.selectionStart
const e = ta.selectionEnd
const val = bodyText.value
const lineStart = val.lastIndexOf('\n', s - 1) + 1
const block = val.slice(lineStart, e)
const prefixed = block
.split('\n')
.map((ln) => prefix + ln)
.join('\n')
bodyText.value = val.slice(0, lineStart) + prefixed + val.slice(e)
nextTick(() => {
ta.focus()
ta.setSelectionRange(lineStart, lineStart + prefixed.length)
})
}
// 현재 위치에 블록 삽입(코드블록/구분선)
function insertBlock(block) {
const ta = taRef.value
if (!ta) return
const s = ta.selectionStart
const val = bodyText.value
const nl = s > 0 && val[s - 1] !== '\n' ? '\n' : ''
const insert = nl + block + '\n'
bodyText.value = val.slice(0, s) + insert + val.slice(s)
nextTick(() => {
ta.focus()
const pos = s + insert.length
ta.setSelectionRange(pos, pos)
})
}
function insertLink() {
const ta = taRef.value
if (!ta) return
const s = ta.selectionStart
const e = ta.selectionEnd
const val = bodyText.value
const sel = val.slice(s, e) || '링크텍스트'
const md = `[${sel}](url)`
bodyText.value = val.slice(0, s) + md + val.slice(e)
nextTick(() => {
ta.focus()
const urlStart = s + sel.length + 3
ta.setSelectionRange(urlStart, urlStart + 3)
})
}
// 이미지: 파일 → 압축 → 서버 업로드 → 첨부 목록에 추가(본문 텍스트엔 미노출)
function pickImage() {
imgInput.value?.click()
}
async function onImagePick(e) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
imgBusy.value = true
imgMsg.value = '이미지 업로드 중…'
try {
const url = await uploadCompressed(file)
const alt = file.name.replace(/\.[^.]+$/, '')
images.value = [...images.value, { url, alt }]
imgMsg.value = ''
} catch (err) {
flashImgMsg(err?.response?.data?.message || '이미지 업로드에 실패했습니다.')
} finally {
imgBusy.value = false
}
}
function removeImage(i) {
images.value = images.value.filter((_, idx) => idx !== i)
}
/* ===================== 웹/APK: Toast UI 위지윅 ===================== */
const el = ref(null)
let editor = null
onMounted(() => {
if (isElectron) return
editor = new Editor({
el: el.value,
height: props.height,
initialEditType: props.initialEditType,
hideModeSwitch: true,
plugins: [codeSyntaxHighlight],
initialValue: props.modelValue || '',
placeholder: props.placeholder,
usageStatistics: false,
autofocus: false,
events: {
change: () => {
lastEmitted = editor.getMarkdown()
emit('update:modelValue', lastEmitted)
},
},
hooks: {
// 이미지 추가 시 base64 대신 서버 업로드 → URL 삽입
addImageBlobHook: async (blob, callback) => {
try {
const url = await uploadCompressed(blob)
callback(url, '')
} catch {
/* 업로드 실패 시 미삽입 */
}
return false
},
},
})
})
watch(
() => props.modelValue,
(val) => {
if (isElectron) return
const v = val || ''
if (v === lastEmitted) return
if (editor && v !== editor.getMarkdown()) {
lastEmitted = v
editor.setMarkdown(v)
}
},
)
onBeforeUnmount(() => {
editor?.destroy()
editor = null
})
</script>
<template>
<!-- PC(Electron): textarea 기반 마크다운 에디터 (툴바 + 이미지 첨부 썸네일) 한글 IME 정상 -->
<div v-if="isElectron" class="mde">
<div class="mde-toolbar">
<button type="button" title="제목" @click="linePrefix('## ')"><span class="t-h">H</span></button>
<button type="button" title="굵게" @click="surround('**', '**', '굵게')"><b>B</b></button>
<button type="button" title="기울임" @click="surround('*', '*', '기울임')"><i>I</i></button>
<button type="button" title="취소선" @click="surround('~~', '~~', '취소선')"><s>S</s></button>
<span class="mde-div"></span>
<button type="button" title="인용" @click="linePrefix('&gt; ')"></button>
<button type="button" title="목록" @click="linePrefix('- ')"></button>
<button type="button" title="번호 목록" @click="linePrefix('1. ')">1.</button>
<button type="button" title="체크리스트" @click="linePrefix('- [ ] ')"></button>
<span class="mde-div"></span>
<button type="button" title="링크" @click="insertLink()">🔗</button>
<button type="button" :title="imgBusy ? '이미지 처리 중…' : '이미지 첨부'" :disabled="imgBusy" @click="pickImage()">🖼</button>
<button type="button" title="인라인 코드" @click="surround('`', '`', '코드')">&lt;/&gt;</button>
<button type="button" title="코드 블록" @click="insertBlock('```\n코드\n```')">{ }</button>
<button type="button" title="구분선" @click="insertBlock('---')"></button>
<input ref="imgInput" type="file" accept="image/*" hidden @change="onImagePick" />
</div>
<p v-if="imgMsg" class="mde-imgmsg">{{ imgMsg }}</p>
<!-- 첨부 이미지 썸네일 본문엔 텍스트만, 이미지는 여기서 확인/삭제 -->
<div v-if="images.length" class="mde-thumbs">
<div v-for="(img, i) in images" :key="i" class="mde-thumb-wrap">
<img :src="img.url" class="mde-thumb" alt="" :title="img.alt || img.url" />
<button type="button" class="mde-thumb-del" title="이미지 제거" @click="removeImage(i)">×</button>
</div>
</div>
<textarea
ref="taRef"
v-model="bodyText"
class="mde-textarea"
:placeholder="placeholder"
:style="{ height }"
></textarea>
</div>
<!-- /APK: Toast UI 위지윅 -->
<div v-else ref="el"></div>
</template>
<style scoped>
.mde {
border: 1px solid var(--color-border);
border-radius: 6px;
overflow: hidden;
background: var(--color-background-soft);
}
.mde-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.15rem;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-background);
}
.mde-toolbar button {
min-width: 1.9rem;
height: 1.9rem;
padding: 0 0.4rem;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--color-text);
font-size: 0.9rem;
cursor: pointer;
line-height: 1;
}
.mde-toolbar button:hover {
background: var(--color-background-mute);
}
.mde-toolbar .t-h {
font-weight: 800;
}
.mde-div {
width: 1px;
height: 1.1rem;
margin: 0 0.25rem;
background: var(--color-border);
}
.mde-imgmsg {
margin: 0;
padding: 0.4rem 0.9rem;
font-size: 0.82rem;
color: hsla(160, 100%, 32%, 1);
background: hsla(160, 100%, 37%, 0.08);
border-bottom: 1px solid var(--color-border);
}
.mde-thumbs {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
padding: 0.6rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-background);
}
.mde-thumb-wrap {
position: relative;
flex: 0 0 auto;
}
.mde-thumb {
display: block;
height: 120px;
max-width: 220px;
object-fit: cover;
border: 1px solid var(--color-border);
border-radius: 6px;
}
.mde-thumb-del {
position: absolute;
top: 5px;
right: 5px;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 1.05rem;
line-height: 1;
cursor: pointer;
}
.mde-thumb-del:hover {
background: rgba(0, 0, 0, 0.78);
}
.mde-textarea {
display: block;
width: 100%;
box-sizing: border-box;
padding: 0.8rem 0.9rem;
border: 0;
background: var(--color-background);
color: var(--color-text);
font-family: inherit;
font-size: 0.95rem;
line-height: 1.7;
resize: vertical;
}
.mde-textarea:focus {
outline: none;
}
</style>