|
|
@@ -7,6 +7,7 @@ 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'
|
|
|
|
import MarkdownViewer from '@/components/editor/MarkdownViewer.vue'
|
|
|
|
import { imageToBlob } from '@/utils/receiptOcr'
|
|
|
|
import { imageToBlob } from '@/utils/receiptOcr'
|
|
|
|
|
|
|
|
import { boardApi } from '@/api/boardApi'
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
const props = defineProps({
|
|
|
|
modelValue: { type: String, default: '' },
|
|
|
|
modelValue: { type: String, default: '' },
|
|
|
@@ -104,32 +105,37 @@ function insertLink() {
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 이미지: 파일 선택 → 리사이즈/압축(JPEG) → base64 임베드 (웹 에디터와 동일하게 본문에 포함, 별도 서버 저장 없음)
|
|
|
|
// 이미지: 파일 → 리사이즈/압축(JPEG) → 서버 업로드(DB 저장) → 본문엔 /api/images/{id} URL 만 삽입
|
|
|
|
const imgInput = ref(null)
|
|
|
|
const imgInput = ref(null)
|
|
|
|
const imgBusy = ref(false)
|
|
|
|
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()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function blobToDataUrl(blob) {
|
|
|
|
// 압축 후 업로드 → URL 반환 (PC textarea·Toast UI 공용)
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
async function uploadCompressed(fileOrBlob) {
|
|
|
|
const r = new FileReader()
|
|
|
|
const blob = await imageToBlob(fileOrBlob, 1280) // 긴 변 1280px, JPEG 0.85
|
|
|
|
r.onload = () => resolve(r.result)
|
|
|
|
const { url } = await boardApi.uploadImage(blob)
|
|
|
|
r.onerror = reject
|
|
|
|
return url
|
|
|
|
r.readAsDataURL(blob)
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
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 = ''
|
|
|
|
if (!file) return
|
|
|
|
if (!file) return
|
|
|
|
imgBusy.value = true
|
|
|
|
imgBusy.value = true
|
|
|
|
|
|
|
|
imgMsg.value = '이미지 업로드 중…'
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
const blob = await imageToBlob(file, 1280) // 긴 변 1280px, JPEG 0.85 압축
|
|
|
|
const url = await uploadCompressed(file)
|
|
|
|
const dataUrl = await blobToDataUrl(blob)
|
|
|
|
|
|
|
|
const ta = taRef.value
|
|
|
|
const ta = taRef.value
|
|
|
|
const s = ta ? ta.selectionStart : text.value.length
|
|
|
|
const s = ta ? ta.selectionStart : text.value.length
|
|
|
|
const alt = file.name.replace(/\.[^.]+$/, '')
|
|
|
|
const alt = file.name.replace(/\.[^.]+$/, '')
|
|
|
|
const md = ``
|
|
|
|
const md = ``
|
|
|
|
text.value = text.value.slice(0, s) + md + text.value.slice(s)
|
|
|
|
text.value = text.value.slice(0, s) + md + text.value.slice(s)
|
|
|
|
nextTick(() => {
|
|
|
|
nextTick(() => {
|
|
|
|
if (!ta) return
|
|
|
|
if (!ta) return
|
|
|
@@ -137,8 +143,9 @@ async function onImagePick(e) {
|
|
|
|
const pos = s + md.length
|
|
|
|
const pos = s + md.length
|
|
|
|
ta.setSelectionRange(pos, pos)
|
|
|
|
ta.setSelectionRange(pos, pos)
|
|
|
|
})
|
|
|
|
})
|
|
|
|
} catch {
|
|
|
|
imgMsg.value = ''
|
|
|
|
/* 이미지 처리 실패 무시 */
|
|
|
|
} catch (err) {
|
|
|
|
|
|
|
|
flashImgMsg(err?.response?.data?.message || '이미지 업로드에 실패했습니다.')
|
|
|
|
} finally {
|
|
|
|
} finally {
|
|
|
|
imgBusy.value = false
|
|
|
|
imgBusy.value = false
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -167,6 +174,18 @@ onMounted(() => {
|
|
|
|
emit('update:modelValue', lastEmitted)
|
|
|
|
emit('update:modelValue', lastEmitted)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
|
|
|
|
hooks: {
|
|
|
|
|
|
|
|
// 이미지 추가 시 base64 대신 서버 업로드 → URL 삽입
|
|
|
|
|
|
|
|
addImageBlobHook: async (blob, callback) => {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const url = await uploadCompressed(blob)
|
|
|
|
|
|
|
|
callback(url, '')
|
|
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
|
|
/* 업로드 실패 시 미삽입 */
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
},
|
|
|
|
})
|
|
|
|
})
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
@@ -216,6 +235,7 @@ onBeforeUnmount(() => {
|
|
|
|
<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>
|
|
|
|
<textarea
|
|
|
|
<textarea
|
|
|
|
ref="taRef"
|
|
|
|
ref="taRef"
|
|
|
|
v-model="text"
|
|
|
|
v-model="text"
|
|
|
@@ -298,6 +318,14 @@ onBeforeUnmount(() => {
|
|
|
|
margin: 0 0.25rem;
|
|
|
|
margin: 0 0.25rem;
|
|
|
|
background: var(--color-border);
|
|
|
|
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-textarea {
|
|
|
|
.mde-textarea {
|
|
|
|
display: block;
|
|
|
|
display: block;
|
|
|
|
width: 100%;
|
|
|
|
width: 100%;
|
|
|
|