Merge branch 'dev'
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, watch, nextTick } 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,29 +16,69 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
// PC(Electron)는 Toast UI 에디터(위지윅·CodeMirror)가 Windows IME 와 충돌해 한글 첫 자음이 중복된다
|
// PC(Electron)는 Toast UI 에디터가 Windows IME 와 충돌(첫 자음 중복)하므로 textarea 기반 에디터를 쓴다.
|
||||||
// (tui.editor 업스트림 버그, 일반 input/textarea 는 정상). → PC 에선 textarea 기반 마크다운 에디터를 쓴다.
|
// 웹/모바일(APK)은 Toast UI 위지윅 유지.
|
||||||
// 웹/모바일(APK)은 기존 Toast UI 위지윅 유지.
|
|
||||||
const isElectron = typeof navigator !== 'undefined' && /electron/i.test(navigator.userAgent)
|
const isElectron = typeof navigator !== 'undefined' && /electron/i.test(navigator.userAgent)
|
||||||
|
|
||||||
/* ===================== PC: textarea 기반 마크다운 에디터 ===================== */
|
// 공용: 이미지 압축 후 서버 업로드 → URL 반환
|
||||||
const text = ref(props.modelValue || '')
|
async function uploadCompressed(fileOrBlob) {
|
||||||
const taRef = ref(null)
|
const blob = await imageToBlob(fileOrBlob, 1280) // 긴 변 1280px, JPEG 0.85
|
||||||
// 본문의  이미지 URL 목록 → 편집창 안 썸네일로 확인(데이터URI/서버URL 모두)
|
const { url } = await boardApi.uploadImage(blob)
|
||||||
const images = computed(() => {
|
return url
|
||||||
const re = /!\[[^\]]*\]\(\s*([^)\s]+)[^)]*\)/g
|
}
|
||||||
const out = []
|
|
||||||
let m
|
/* ===================== PC: 본문 텍스트(textarea) + 이미지 첨부(썸네일) ===================== */
|
||||||
while ((m = re.exec(text.value)) !== null) out.push(m[1])
|
// 이미지 마크다운은 본문 텍스트에 노출하지 않고 별도 첨부로 관리 → 저장 시 본문 끝에 합쳐 emit.
|
||||||
return out
|
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) => ``).join('\n\n')
|
||||||
|
return [bodyText.value.trim(), imgMd].filter(Boolean).join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastEmitted = isElectron ? combine() : (props.modelValue || '')
|
||||||
|
|
||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
watch(text, (v) => emit('update:modelValue', v))
|
watch(
|
||||||
|
[bodyText, images],
|
||||||
|
() => {
|
||||||
|
lastEmitted = combine()
|
||||||
|
emit('update:modelValue', lastEmitted)
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(v) => {
|
(v) => {
|
||||||
if ((v || '') !== text.value) text.value = v || ''
|
if ((v || '') === lastEmitted) return
|
||||||
|
const sp = splitContent(v)
|
||||||
|
bodyText.value = sp.body
|
||||||
|
images.value = sp.imgs
|
||||||
|
lastEmitted = v || ''
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -49,87 +89,69 @@ function surround(before, after = before, placeholder = '') {
|
|||||||
if (!ta) return
|
if (!ta) return
|
||||||
const s = ta.selectionStart
|
const s = ta.selectionStart
|
||||||
const e = ta.selectionEnd
|
const e = ta.selectionEnd
|
||||||
const val = text.value
|
const val = bodyText.value
|
||||||
const sel = val.slice(s, e) || placeholder
|
const sel = val.slice(s, e) || placeholder
|
||||||
text.value = val.slice(0, s) + before + sel + after + val.slice(e)
|
bodyText.value = val.slice(0, s) + before + sel + after + val.slice(e)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
ta.focus()
|
ta.focus()
|
||||||
const start = s + before.length
|
const start = s + before.length
|
||||||
ta.setSelectionRange(start, start + sel.length)
|
ta.setSelectionRange(start, start + sel.length)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 선택한 줄들 앞에 접두어 추가(제목/인용/목록 등)
|
// 선택한 줄들 앞에 접두어 추가(제목/인용/목록 등)
|
||||||
function linePrefix(prefix) {
|
function linePrefix(prefix) {
|
||||||
const ta = taRef.value
|
const ta = taRef.value
|
||||||
if (!ta) return
|
if (!ta) return
|
||||||
const s = ta.selectionStart
|
const s = ta.selectionStart
|
||||||
const e = ta.selectionEnd
|
const e = ta.selectionEnd
|
||||||
const val = text.value
|
const val = bodyText.value
|
||||||
const lineStart = val.lastIndexOf('\n', s - 1) + 1
|
const lineStart = val.lastIndexOf('\n', s - 1) + 1
|
||||||
const block = val.slice(lineStart, e)
|
const block = val.slice(lineStart, e)
|
||||||
const prefixed = block
|
const prefixed = block
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((ln) => prefix + ln)
|
.map((ln) => prefix + ln)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
text.value = val.slice(0, lineStart) + prefixed + val.slice(e)
|
bodyText.value = val.slice(0, lineStart) + prefixed + val.slice(e)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
ta.focus()
|
ta.focus()
|
||||||
ta.setSelectionRange(lineStart, lineStart + prefixed.length)
|
ta.setSelectionRange(lineStart, lineStart + prefixed.length)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 현재 위치에 블록 삽입(코드블록/구분선)
|
// 현재 위치에 블록 삽입(코드블록/구분선)
|
||||||
function insertBlock(block) {
|
function insertBlock(block) {
|
||||||
const ta = taRef.value
|
const ta = taRef.value
|
||||||
if (!ta) return
|
if (!ta) return
|
||||||
const s = ta.selectionStart
|
const s = ta.selectionStart
|
||||||
const val = text.value
|
const val = bodyText.value
|
||||||
const nl = s > 0 && val[s - 1] !== '\n' ? '\n' : ''
|
const nl = s > 0 && val[s - 1] !== '\n' ? '\n' : ''
|
||||||
const insert = nl + block + '\n'
|
const insert = nl + block + '\n'
|
||||||
text.value = val.slice(0, s) + insert + val.slice(s)
|
bodyText.value = val.slice(0, s) + insert + val.slice(s)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
ta.focus()
|
ta.focus()
|
||||||
const pos = s + insert.length
|
const pos = s + insert.length
|
||||||
ta.setSelectionRange(pos, pos)
|
ta.setSelectionRange(pos, pos)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertLink() {
|
function insertLink() {
|
||||||
const ta = taRef.value
|
const ta = taRef.value
|
||||||
if (!ta) return
|
if (!ta) return
|
||||||
const s = ta.selectionStart
|
const s = ta.selectionStart
|
||||||
const e = ta.selectionEnd
|
const e = ta.selectionEnd
|
||||||
const val = text.value
|
const val = bodyText.value
|
||||||
const sel = val.slice(s, e) || '링크텍스트'
|
const sel = val.slice(s, e) || '링크텍스트'
|
||||||
const md = `[${sel}](url)`
|
const md = `[${sel}](url)`
|
||||||
text.value = val.slice(0, s) + md + val.slice(e)
|
bodyText.value = val.slice(0, s) + md + val.slice(e)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
ta.focus()
|
ta.focus()
|
||||||
const urlStart = s + sel.length + 3 // "[sel]("
|
const urlStart = s + sel.length + 3
|
||||||
ta.setSelectionRange(urlStart, urlStart + 3)
|
ta.setSelectionRange(urlStart, urlStart + 3)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미지: 파일 → 리사이즈/압축(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() {
|
function pickImage() {
|
||||||
imgInput.value?.click()
|
imgInput.value?.click()
|
||||||
}
|
}
|
||||||
// 압축 후 업로드 → 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) {
|
async function onImagePick(e) {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
@@ -138,17 +160,8 @@ async function onImagePick(e) {
|
|||||||
imgMsg.value = '이미지 업로드 중…'
|
imgMsg.value = '이미지 업로드 중…'
|
||||||
try {
|
try {
|
||||||
const url = await uploadCompressed(file)
|
const url = await uploadCompressed(file)
|
||||||
const ta = taRef.value
|
|
||||||
const s = ta ? ta.selectionStart : text.value.length
|
|
||||||
const alt = file.name.replace(/\.[^.]+$/, '')
|
const alt = file.name.replace(/\.[^.]+$/, '')
|
||||||
const md = ``
|
images.value = [...images.value, { url, alt }]
|
||||||
text.value = text.value.slice(0, s) + md + text.value.slice(s)
|
|
||||||
nextTick(() => {
|
|
||||||
if (!ta) return
|
|
||||||
ta.focus()
|
|
||||||
const pos = s + md.length
|
|
||||||
ta.setSelectionRange(pos, pos)
|
|
||||||
})
|
|
||||||
imgMsg.value = ''
|
imgMsg.value = ''
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
flashImgMsg(err?.response?.data?.message || '이미지 업로드에 실패했습니다.')
|
flashImgMsg(err?.response?.data?.message || '이미지 업로드에 실패했습니다.')
|
||||||
@@ -156,11 +169,13 @@ async function onImagePick(e) {
|
|||||||
imgBusy.value = false
|
imgBusy.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function removeImage(i) {
|
||||||
|
images.value = images.value.filter((_, idx) => idx !== i)
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== 웹/APK: Toast UI 위지윅 ===================== */
|
/* ===================== 웹/APK: Toast UI 위지윅 ===================== */
|
||||||
const el = ref(null)
|
const el = ref(null)
|
||||||
let editor = null
|
let editor = null
|
||||||
let lastEmitted = props.modelValue || ''
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (isElectron) return
|
if (isElectron) return
|
||||||
@@ -215,7 +230,7 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- PC(Electron): textarea 기반 마크다운 에디터 (툴바 + 이미지 썸네일) — 한글 IME 정상 -->
|
<!-- PC(Electron): textarea 기반 마크다운 에디터 (툴바 + 이미지 첨부 썸네일) — 한글 IME 정상 -->
|
||||||
<div v-if="isElectron" class="mde">
|
<div v-if="isElectron" class="mde">
|
||||||
<div class="mde-toolbar">
|
<div class="mde-toolbar">
|
||||||
<button type="button" title="제목" @click="linePrefix('## ')"><span class="t-h">H</span></button>
|
<button type="button" title="제목" @click="linePrefix('## ')"><span class="t-h">H</span></button>
|
||||||
@@ -229,20 +244,23 @@ onBeforeUnmount(() => {
|
|||||||
<button type="button" title="체크리스트" @click="linePrefix('- [ ] ')">☑</button>
|
<button type="button" title="체크리스트" @click="linePrefix('- [ ] ')">☑</button>
|
||||||
<span class="mde-div"></span>
|
<span class="mde-div"></span>
|
||||||
<button type="button" title="링크" @click="insertLink()">🔗</button>
|
<button type="button" title="링크" @click="insertLink()">🔗</button>
|
||||||
<button type="button" :title="imgBusy ? '이미지 처리 중…' : '이미지'" :disabled="imgBusy" @click="pickImage()">🖼</button>
|
<button type="button" :title="imgBusy ? '이미지 처리 중…' : '이미지 첨부'" :disabled="imgBusy" @click="pickImage()">🖼</button>
|
||||||
<button type="button" title="인라인 코드" @click="surround('`', '`', '코드')"></></button>
|
<button type="button" title="인라인 코드" @click="surround('`', '`', '코드')"></></button>
|
||||||
<button type="button" title="코드 블록" @click="insertBlock('```\n코드\n```')">{ }</button>
|
<button type="button" title="코드 블록" @click="insertBlock('```\n코드\n```')">{ }</button>
|
||||||
<button type="button" title="구분선" @click="insertBlock('---')">―</button>
|
<button type="button" title="구분선" @click="insertBlock('---')">―</button>
|
||||||
<input ref="imgInput" type="file" accept="image/*" hidden @change="onImagePick" />
|
<input ref="imgInput" type="file" accept="image/*" hidden @change="onImagePick" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="imgMsg" class="mde-imgmsg">{{ imgMsg }}</p>
|
<p v-if="imgMsg" class="mde-imgmsg">{{ imgMsg }}</p>
|
||||||
<!-- 본문에 들어간 이미지 썸네일 — 편집창 안에서 바로 확인 -->
|
<!-- 첨부 이미지 썸네일 — 본문엔 텍스트만, 이미지는 여기서 확인/삭제 -->
|
||||||
<div v-if="images.length" class="mde-thumbs">
|
<div v-if="images.length" class="mde-thumbs">
|
||||||
<img v-for="(src, i) in images" :key="i" :src="src" class="mde-thumb" alt="" :title="src" />
|
<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>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
ref="taRef"
|
ref="taRef"
|
||||||
v-model="text"
|
v-model="bodyText"
|
||||||
class="mde-textarea"
|
class="mde-textarea"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:style="{ height }"
|
:style="{ height }"
|
||||||
@@ -260,22 +278,6 @@ onBeforeUnmount(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-background-soft);
|
background: var(--color-background-soft);
|
||||||
}
|
}
|
||||||
.mde-thumbs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.45rem;
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
background: var(--color-background);
|
|
||||||
}
|
|
||||||
.mde-thumb {
|
|
||||||
height: 56px;
|
|
||||||
max-width: 96px;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
.mde-toolbar {
|
.mde-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -317,6 +319,44 @@ onBeforeUnmount(() => {
|
|||||||
background: hsla(160, 100%, 37%, 0.08);
|
background: hsla(160, 100%, 37%, 0.08);
|
||||||
border-bottom: 1px solid var(--color-border);
|
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 {
|
.mde-textarea {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user