2026-05-31 15:42:52 +09:00
|
|
|
<script setup>
|
2026-06-07 18:08:23 +09:00
|
|
|
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
2026-05-31 15:42:52 +09:00
|
|
|
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'
|
2026-06-07 18:08:23 +09:00
|
|
|
import MarkdownViewer from '@/components/editor/MarkdownViewer.vue'
|
2026-06-07 18:14:40 +09:00
|
|
|
import { imageToBlob } from '@/utils/receiptOcr'
|
2026-06-07 19:17:44 +09:00
|
|
|
import { boardApi } from '@/api/boardApi'
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
|
|
|
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'])
|
|
|
|
|
|
2026-06-07 18:08:23 +09:00
|
|
|
// PC(Electron)는 Toast UI 에디터(위지윅·CodeMirror)가 Windows IME 와 충돌해 한글 첫 자음이 중복된다
|
|
|
|
|
// (tui.editor 업스트림 버그, 일반 input/textarea 는 정상). → PC 에선 textarea 기반 마크다운 에디터를 쓴다.
|
2026-06-07 17:56:20 +09:00
|
|
|
// 웹/모바일(APK)은 기존 Toast UI 위지윅 유지.
|
2026-06-07 17:50:43 +09:00
|
|
|
const isElectron = typeof navigator !== 'undefined' && /electron/i.test(navigator.userAgent)
|
|
|
|
|
|
2026-06-07 18:08:23 +09:00
|
|
|
/* ===================== PC: textarea 기반 마크다운 에디터 ===================== */
|
2026-06-07 17:56:20 +09:00
|
|
|
const text = ref(props.modelValue || '')
|
2026-06-07 18:08:23 +09:00
|
|
|
const taRef = ref(null)
|
|
|
|
|
const mode = ref('write') // 'write' | 'preview'
|
|
|
|
|
|
2026-06-07 17:56:20 +09:00
|
|
|
if (isElectron) {
|
|
|
|
|
watch(text, (v) => emit('update:modelValue', v))
|
|
|
|
|
watch(
|
|
|
|
|
() => props.modelValue,
|
|
|
|
|
(v) => {
|
|
|
|
|
if ((v || '') !== text.value) text.value = v || ''
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 18:08:23 +09:00
|
|
|
// 선택 영역을 마커로 감싸기(굵게/기울임/코드 등)
|
|
|
|
|
function surround(before, after = before, placeholder = '') {
|
|
|
|
|
const ta = taRef.value
|
|
|
|
|
if (!ta) return
|
|
|
|
|
const s = ta.selectionStart
|
|
|
|
|
const e = ta.selectionEnd
|
|
|
|
|
const val = text.value
|
|
|
|
|
const sel = val.slice(s, e) || placeholder
|
|
|
|
|
text.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 = text.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')
|
|
|
|
|
text.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 = text.value
|
|
|
|
|
const nl = s > 0 && val[s - 1] !== '\n' ? '\n' : ''
|
|
|
|
|
const insert = nl + block + '\n'
|
|
|
|
|
text.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 = text.value
|
|
|
|
|
const sel = val.slice(s, e) || '링크텍스트'
|
|
|
|
|
const md = `[${sel}](url)`
|
|
|
|
|
text.value = val.slice(0, s) + md + val.slice(e)
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
ta.focus()
|
|
|
|
|
const urlStart = s + sel.length + 3 // "[sel]("
|
|
|
|
|
ta.setSelectionRange(urlStart, urlStart + 3)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 19:17:44 +09:00
|
|
|
// 이미지: 파일 → 리사이즈/압축(JPEG) → 서버 업로드(DB 저장) → 본문엔 /api/images/{id} URL 만 삽입
|
2026-06-07 18:14:40 +09:00
|
|
|
const imgInput = ref(null)
|
|
|
|
|
const imgBusy = ref(false)
|
2026-06-07 19:17:44 +09:00
|
|
|
const imgMsg = ref('')
|
|
|
|
|
let imgMsgTimer = null
|
|
|
|
|
function flashImgMsg(msg) {
|
|
|
|
|
imgMsg.value = msg
|
|
|
|
|
if (imgMsgTimer) clearTimeout(imgMsgTimer)
|
|
|
|
|
imgMsgTimer = setTimeout(() => (imgMsg.value = ''), 4000)
|
|
|
|
|
}
|
2026-06-07 18:14:40 +09:00
|
|
|
function pickImage() {
|
|
|
|
|
imgInput.value?.click()
|
|
|
|
|
}
|
2026-06-07 19:17:44 +09:00
|
|
|
// 압축 후 업로드 → 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
|
2026-06-07 18:14:40 +09:00
|
|
|
}
|
|
|
|
|
async function onImagePick(e) {
|
|
|
|
|
const file = e.target.files?.[0]
|
|
|
|
|
e.target.value = ''
|
|
|
|
|
if (!file) return
|
|
|
|
|
imgBusy.value = true
|
2026-06-07 19:17:44 +09:00
|
|
|
imgMsg.value = '이미지 업로드 중…'
|
2026-06-07 18:14:40 +09:00
|
|
|
try {
|
2026-06-07 19:17:44 +09:00
|
|
|
const url = await uploadCompressed(file)
|
2026-06-07 18:14:40 +09:00
|
|
|
const ta = taRef.value
|
|
|
|
|
const s = ta ? ta.selectionStart : text.value.length
|
|
|
|
|
const alt = file.name.replace(/\.[^.]+$/, '')
|
2026-06-07 19:17:44 +09:00
|
|
|
const md = ``
|
2026-06-07 18:14:40 +09:00
|
|
|
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)
|
|
|
|
|
})
|
2026-06-07 19:17:44 +09:00
|
|
|
imgMsg.value = ''
|
|
|
|
|
} catch (err) {
|
|
|
|
|
flashImgMsg(err?.response?.data?.message || '이미지 업로드에 실패했습니다.')
|
2026-06-07 18:14:40 +09:00
|
|
|
} finally {
|
|
|
|
|
imgBusy.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 18:08:23 +09:00
|
|
|
/* ===================== 웹/APK: Toast UI 위지윅 ===================== */
|
2026-05-31 15:42:52 +09:00
|
|
|
const el = ref(null)
|
|
|
|
|
let editor = null
|
2026-05-31 19:52:23 +09:00
|
|
|
let lastEmitted = props.modelValue || ''
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
|
|
|
onMounted(() => {
|
2026-06-07 17:56:20 +09:00
|
|
|
if (isElectron) return
|
2026-05-31 15:42:52 +09:00
|
|
|
editor = new Editor({
|
|
|
|
|
el: el.value,
|
|
|
|
|
height: props.height,
|
2026-06-07 17:56:20 +09:00
|
|
|
initialEditType: props.initialEditType,
|
2026-06-07 17:50:43 +09:00
|
|
|
hideModeSwitch: true,
|
2026-06-07 17:56:20 +09:00
|
|
|
plugins: [codeSyntaxHighlight],
|
2026-05-31 15:42:52 +09:00
|
|
|
initialValue: props.modelValue || '',
|
|
|
|
|
placeholder: props.placeholder,
|
|
|
|
|
usageStatistics: false,
|
|
|
|
|
autofocus: false,
|
|
|
|
|
events: {
|
2026-05-31 19:52:23 +09:00
|
|
|
change: () => {
|
2026-06-07 17:50:43 +09:00
|
|
|
lastEmitted = editor.getMarkdown()
|
|
|
|
|
emit('update:modelValue', lastEmitted)
|
2026-05-31 19:52:23 +09:00
|
|
|
},
|
2026-05-31 15:42:52 +09:00
|
|
|
},
|
2026-06-07 19:17:44 +09:00
|
|
|
hooks: {
|
|
|
|
|
// 이미지 추가 시 base64 대신 서버 업로드 → URL 삽입
|
|
|
|
|
addImageBlobHook: async (blob, callback) => {
|
|
|
|
|
try {
|
|
|
|
|
const url = await uploadCompressed(blob)
|
|
|
|
|
callback(url, '')
|
|
|
|
|
} catch {
|
|
|
|
|
/* 업로드 실패 시 미삽입 */
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-05-31 15:42:52 +09:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => props.modelValue,
|
|
|
|
|
(val) => {
|
2026-06-07 17:56:20 +09:00
|
|
|
if (isElectron) return
|
2026-05-31 19:52:23 +09:00
|
|
|
const v = val || ''
|
2026-06-07 17:50:43 +09:00
|
|
|
if (v === lastEmitted) return
|
2026-05-31 19:52:23 +09:00
|
|
|
if (editor && v !== editor.getMarkdown()) {
|
|
|
|
|
lastEmitted = v
|
|
|
|
|
editor.setMarkdown(v)
|
2026-05-31 15:42:52 +09:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
editor?.destroy()
|
|
|
|
|
editor = null
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-06-07 18:08:23 +09:00
|
|
|
<!-- PC(Electron): textarea 기반 마크다운 에디터 (툴바 + 미리보기) — 한글 IME 정상 -->
|
|
|
|
|
<div v-if="isElectron" class="mde">
|
|
|
|
|
<div class="mde-tabs">
|
|
|
|
|
<button type="button" :class="{ active: mode === 'write' }" @click="mode = 'write'">Write</button>
|
|
|
|
|
<button type="button" :class="{ active: mode === 'preview' }" @click="mode = 'preview'">Preview</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-show="mode === 'write'">
|
|
|
|
|
<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('> ')">❝</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>
|
2026-06-07 18:14:40 +09:00
|
|
|
<button type="button" :title="imgBusy ? '이미지 처리 중…' : '이미지'" :disabled="imgBusy" @click="pickImage()">🖼</button>
|
2026-06-07 18:08:23 +09:00
|
|
|
<button type="button" title="인라인 코드" @click="surround('`', '`', '코드')"></></button>
|
|
|
|
|
<button type="button" title="코드 블록" @click="insertBlock('```\n코드\n```')">{ }</button>
|
|
|
|
|
<button type="button" title="구분선" @click="insertBlock('---')">―</button>
|
2026-06-07 18:14:40 +09:00
|
|
|
<input ref="imgInput" type="file" accept="image/*" hidden @change="onImagePick" />
|
2026-06-07 18:08:23 +09:00
|
|
|
</div>
|
2026-06-07 19:17:44 +09:00
|
|
|
<p v-if="imgMsg" class="mde-imgmsg">{{ imgMsg }}</p>
|
2026-06-07 18:08:23 +09:00
|
|
|
<textarea
|
|
|
|
|
ref="taRef"
|
|
|
|
|
v-model="text"
|
|
|
|
|
class="mde-textarea"
|
|
|
|
|
:placeholder="placeholder"
|
|
|
|
|
:style="{ height }"
|
|
|
|
|
></textarea>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-show="mode === 'preview'" class="mde-preview" :style="{ minHeight: height }">
|
|
|
|
|
<MarkdownViewer v-if="text.trim()" :content="text" />
|
|
|
|
|
<p v-else class="mde-empty">미리볼 내용이 없습니다.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 웹/APK: Toast UI 위지윅 -->
|
2026-06-07 17:56:20 +09:00
|
|
|
<div v-else ref="el"></div>
|
2026-05-31 15:42:52 +09:00
|
|
|
</template>
|
2026-06-07 17:56:20 +09:00
|
|
|
|
|
|
|
|
<style scoped>
|
2026-06-07 18:08:23 +09:00
|
|
|
.mde {
|
2026-06-07 17:56:20 +09:00
|
|
|
border: 1px solid var(--color-border);
|
2026-06-07 18:08:23 +09:00
|
|
|
border-radius: 6px;
|
|
|
|
|
overflow: hidden;
|
2026-06-07 17:56:20 +09:00
|
|
|
background: var(--color-background-soft);
|
2026-06-07 18:08:23 +09:00
|
|
|
}
|
|
|
|
|
.mde-tabs {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
padding: 0.35rem 0.5rem 0;
|
|
|
|
|
border-bottom: 1px solid var(--color-border);
|
|
|
|
|
}
|
|
|
|
|
.mde-tabs button {
|
|
|
|
|
padding: 0.35rem 0.9rem;
|
|
|
|
|
border: 1px solid transparent;
|
|
|
|
|
border-bottom: 0;
|
|
|
|
|
border-radius: 6px 6px 0 0;
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
opacity: 0.6;
|
|
|
|
|
}
|
|
|
|
|
.mde-tabs button.active {
|
|
|
|
|
background: var(--color-background);
|
|
|
|
|
border-color: var(--color-border);
|
|
|
|
|
opacity: 1;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
.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);
|
|
|
|
|
}
|
2026-06-07 19:17:44 +09:00
|
|
|
.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);
|
|
|
|
|
}
|
2026-06-07 18:08:23 +09:00
|
|
|
.mde-textarea {
|
|
|
|
|
display: block;
|
|
|
|
|
width: 100%;
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
padding: 0.8rem 0.9rem;
|
|
|
|
|
border: 0;
|
|
|
|
|
background: var(--color-background);
|
2026-06-07 17:56:20 +09:00
|
|
|
color: var(--color-text);
|
|
|
|
|
font-family: inherit;
|
|
|
|
|
font-size: 0.95rem;
|
2026-06-07 18:08:23 +09:00
|
|
|
line-height: 1.7;
|
2026-06-07 17:56:20 +09:00
|
|
|
resize: vertical;
|
|
|
|
|
}
|
2026-06-07 18:08:23 +09:00
|
|
|
.mde-textarea:focus {
|
2026-06-07 17:56:20 +09:00
|
|
|
outline: none;
|
2026-06-07 18:08:23 +09:00
|
|
|
}
|
|
|
|
|
.mde-preview {
|
|
|
|
|
padding: 0.8rem 0.9rem;
|
|
|
|
|
background: var(--color-background);
|
|
|
|
|
overflow: auto;
|
|
|
|
|
}
|
|
|
|
|
.mde-empty {
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
margin: 0;
|
2026-06-07 17:56:20 +09:00
|
|
|
}
|
|
|
|
|
</style>
|