feat: PC용 툴바 마크다운 에디터 (한글 IME 정상)
CI / build (push) Successful in 34s

- Toast UI 는 Windows IME 와 충돌(업스트림 버그)하므로 PC(Electron)에선 textarea 기반
  마크다운 에디터 사용: 툴바(제목·굵게·기울임·취소선·인용·목록·체크·링크·코드·구분선) + Write/Preview 탭
- textarea(v-model)라 한글 IME 정상. 웹/APK 는 Toast UI 위지윅 유지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-07 18:08:23 +09:00
parent 9c61f888a5
commit b4aa5c2ad0
+191 -17
View File
@@ -1,10 +1,11 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount, watch } 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'
import 'prismjs/themes/prism-tomorrow.css' import 'prismjs/themes/prism-tomorrow.css'
import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css' import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css'
import MarkdownViewer from '@/components/editor/MarkdownViewer.vue'
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
@@ -14,13 +15,16 @@ const props = defineProps({
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
// PC(Electron)는 Toast UI 에디터(위지윅·CodeMirror 모두)에서 한글 IME 첫 자음이 중복되는 // PC(Electron)는 Toast UI 에디터(위지윅·CodeMirror)가 Windows IME 와 충돌해 한글 첫 자음이 중복된다
// Chromium 버그가 있다(제목 등 일반 입력은 정상). → PC 에선 일반 textarea(네이티브 IME)로 입력받는다. // (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 (Vue v-model 이 IME 조합을 올바르게 처리) ===== /* ===================== PC: textarea 기반 마크다운 에디터 ===================== */
const text = ref(props.modelValue || '') const text = ref(props.modelValue || '')
const taRef = ref(null)
const mode = ref('write') // 'write' | 'preview'
if (isElectron) { if (isElectron) {
watch(text, (v) => emit('update:modelValue', v)) watch(text, (v) => emit('update:modelValue', v))
watch( watch(
@@ -31,7 +35,75 @@ if (isElectron) {
) )
} }
// ===== 웹/APK: Toast UI 위지윅 ===== // 선택 영역을 마커로 감싸기(굵게/기울임/코드 등)
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)
})
}
/* ===================== 웹/APK: Toast UI 위지윅 ===================== */
const el = ref(null) const el = ref(null)
let editor = null let editor = null
let lastEmitted = props.modelValue || '' let lastEmitted = props.modelValue || ''
@@ -77,34 +149,136 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<!-- PC(Electron): 일반 textarea 한글 IME 정상 --> <!-- 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('&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="인라인 코드" @click="surround('`', '`', '코드')">&lt;/&gt;</button>
<button type="button" title="코드 블록" @click="insertBlock('```\n코드\n```')">{ }</button>
<button type="button" title="구분선" @click="insertBlock('---')"></button>
</div>
<textarea <textarea
v-if="isElectron" ref="taRef"
v-model="text" v-model="text"
class="md-textarea" class="mde-textarea"
:placeholder="placeholder" :placeholder="placeholder"
:style="{ height }" :style="{ height }"
></textarea> ></textarea>
<!-- /APK: Toast UI 에디터 --> </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 위지윅 -->
<div v-else ref="el"></div> <div v-else ref="el"></div>
</template> </template>
<style scoped> <style scoped>
.md-textarea { .mde {
border: 1px solid var(--color-border);
border-radius: 6px;
overflow: hidden;
background: var(--color-background-soft);
}
.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);
}
.mde-textarea {
display: block;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 0.75rem 0.9rem; padding: 0.8rem 0.9rem;
border: 1px solid var(--color-border); border: 0;
border-radius: 4px; background: var(--color-background);
background: var(--color-background-soft);
color: var(--color-text); color: var(--color-text);
font-family: inherit; font-family: inherit;
font-size: 0.95rem; font-size: 0.95rem;
line-height: 1.6; line-height: 1.7;
resize: vertical; resize: vertical;
} }
.md-textarea:focus { .mde-textarea:focus {
outline: none; outline: none;
border-color: hsla(160, 100%, 37%, 1); }
.mde-preview {
padding: 0.8rem 0.9rem;
background: var(--color-background);
overflow: auto;
}
.mde-empty {
opacity: 0.5;
font-size: 0.9rem;
margin: 0;
} }
</style> </style>