Merge branch 'dev'
Deploy / deploy (push) Failing after 11m19s
CI / build (push) Failing after 12m45s

This commit is contained in:
ByungCheol
2026-06-07 20:03:44 +09:00
+10 -302
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue' import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import Editor from '@toast-ui/editor' import Editor from '@toast-ui/editor'
import '@toast-ui/editor/dist/toastui-editor.css' 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 codeSyntaxHighlight from '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight-all'
@@ -16,175 +16,25 @@ const props = defineProps({
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
// PC(Electron)는 Toast UI 에디터가 Windows IME 와 충돌(첫 자음 중복)하므로 textarea 기반 에디터를 쓴다. const el = ref(null)
// 웹/모바일(APK)은 Toast UI 위지윅 유지. let editor = null
const isElectron = typeof navigator !== 'undefined' && /electron/i.test(navigator.userAgent) // 에디터가 마지막으로 emit 한 값. 부모가 되돌려준 echo 를 구분해 입력 중 setMarkdown 으로 끊기지 않게.
let lastEmitted = props.modelValue || ''
// 공용: 이미지 압축 후 서버 업로드 → URL 반환 // 이미지: 압축 후 서버 업로드 → URL (base64 대신 본문엔 짧은 URL 만)
async function uploadCompressed(fileOrBlob) { async function uploadCompressed(fileOrBlob) {
const blob = await imageToBlob(fileOrBlob, 1280) // 긴 변 1280px, JPEG 0.85 const blob = await imageToBlob(fileOrBlob, 1280) // 긴 변 1280px, JPEG 0.85
const { url } = await boardApi.uploadImage(blob) const { url } = await boardApi.uploadImage(blob)
return url 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(() => { onMounted(() => {
if (isElectron) return
editor = new Editor({ editor = new Editor({
el: el.value, el: el.value,
height: props.height, height: props.height,
initialEditType: props.initialEditType, initialEditType: props.initialEditType,
hideModeSwitch: true, hideModeSwitch: true, // 마크다운/위지윅 전환 탭 숨김 (WYSIWYG 전용)
plugins: [codeSyntaxHighlight], plugins: [codeSyntaxHighlight], // 코드 구문 강조
initialValue: props.modelValue || '', initialValue: props.modelValue || '',
placeholder: props.placeholder, placeholder: props.placeholder,
usageStatistics: false, usageStatistics: false,
@@ -210,10 +60,10 @@ onMounted(() => {
}) })
}) })
// 외부 값 변경(수정 로드, 제출 후 초기화 등)만 에디터에 반영. echo 는 무시.
watch( watch(
() => props.modelValue, () => props.modelValue,
(val) => { (val) => {
if (isElectron) return
const v = val || '' const v = val || ''
if (v === lastEmitted) return if (v === lastEmitted) return
if (editor && v !== editor.getMarkdown()) { if (editor && v !== editor.getMarkdown()) {
@@ -230,147 +80,5 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<!-- PC(Electron): textarea 기반 마크다운 에디터 (툴바 + 이미지 첨부 썸네일) 한글 IME 정상 --> <div ref="el"></div>
<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> </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>