기능구현중

This commit is contained in:
2026-01-11 15:35:33 +09:00
parent 8e0f1f30cf
commit 375d5bf91a
11 changed files with 470 additions and 210 deletions

View File

@@ -0,0 +1,220 @@
<template>
<div class="tiptap-editor">
<!-- 툴바 -->
<div v-if="showToolbar && editor" class="tiptap-toolbar border-bottom bg-light px-2 py-1">
<div class="btn-group btn-group-sm me-2">
<button type="button" class="btn btn-outline-secondary"
:class="{ active: editor.isActive('bold') }"
@click="editor.chain().focus().toggleBold().run()"
title="굵게 (Ctrl+B)">
<i class="bi bi-type-bold"></i>
</button>
<button type="button" class="btn btn-outline-secondary"
:class="{ active: editor.isActive('italic') }"
@click="editor.chain().focus().toggleItalic().run()"
title="기울임 (Ctrl+I)">
<i class="bi bi-type-italic"></i>
</button>
<button type="button" class="btn btn-outline-secondary"
:class="{ active: editor.isActive('strike') }"
@click="editor.chain().focus().toggleStrike().run()"
title="취소선">
<i class="bi bi-type-strikethrough"></i>
</button>
</div>
<div class="btn-group btn-group-sm me-2">
<button type="button" class="btn btn-outline-secondary"
:class="{ active: editor.isActive('heading', { level: 2 }) }"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
title="제목">
<i class="bi bi-type-h2"></i>
</button>
<button type="button" class="btn btn-outline-secondary"
:class="{ active: editor.isActive('heading', { level: 3 }) }"
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
title="소제목">
<i class="bi bi-type-h3"></i>
</button>
</div>
<div class="btn-group btn-group-sm me-2">
<button type="button" class="btn btn-outline-secondary"
:class="{ active: editor.isActive('bulletList') }"
@click="editor.chain().focus().toggleBulletList().run()"
title="글머리 기호">
<i class="bi bi-list-ul"></i>
</button>
<button type="button" class="btn btn-outline-secondary"
:class="{ active: editor.isActive('orderedList') }"
@click="editor.chain().focus().toggleOrderedList().run()"
title="번호 목록">
<i class="bi bi-list-ol"></i>
</button>
<button type="button" class="btn btn-outline-secondary"
:class="{ active: editor.isActive('blockquote') }"
@click="editor.chain().focus().toggleBlockquote().run()"
title="인용">
<i class="bi bi-quote"></i>
</button>
</div>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary"
@click="editor.chain().focus().setHorizontalRule().run()"
title="구분선">
<i class="bi bi-hr"></i>
</button>
<button type="button" class="btn btn-outline-secondary"
@click="editor.chain().focus().undo().run()"
:disabled="!editor.can().undo()"
title="실행 취소 (Ctrl+Z)">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
<button type="button" class="btn btn-outline-secondary"
@click="editor.chain().focus().redo().run()"
:disabled="!editor.can().redo()"
title="다시 실행 (Ctrl+Y)">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
<!-- 에디터 본문 -->
<EditorContent :editor="editor" class="tiptap-content" />
</div>
</template>
<script setup lang="ts">
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import Link from '@tiptap/extension-link'
const props = defineProps<{
modelValue: string
placeholder?: string
showToolbar?: boolean
minHeight?: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const editor = useEditor({
content: props.modelValue || '',
editable: !props.readonly,
extensions: [
StarterKit.configure({
heading: {
levels: [2, 3]
}
}),
Placeholder.configure({
placeholder: props.placeholder || '내용을 입력하세요...'
}),
Link.configure({
openOnClick: false
})
],
onUpdate: ({ editor }) => {
emit('update:modelValue', editor.getHTML())
}
})
// props 변경 감지
watch(() => props.modelValue, (newValue) => {
if (editor.value && newValue !== editor.value.getHTML()) {
editor.value.commands.setContent(newValue || '')
}
})
watch(() => props.readonly, (newValue) => {
if (editor.value) {
editor.value.setEditable(!newValue)
}
})
onBeforeUnmount(() => {
editor.value?.destroy()
})
// 외부에서 접근 가능한 메서드
defineExpose({
getHTML: () => editor.value?.getHTML() || '',
getText: () => editor.value?.getText() || '',
focus: () => editor.value?.commands.focus(),
clear: () => editor.value?.commands.clearContent()
})
</script>
<style scoped>
.tiptap-editor {
border: 1px solid #dee2e6;
border-radius: 0.375rem;
overflow: hidden;
}
.tiptap-toolbar .btn.active {
background-color: #0d6efd;
border-color: #0d6efd;
color: white;
}
.tiptap-content {
min-height: v-bind('minHeight || "300px"');
padding: 1rem;
}
.tiptap-content :deep(.tiptap) {
outline: none;
min-height: inherit;
}
.tiptap-content :deep(.tiptap p.is-editor-empty:first-child::before) {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.tiptap-content :deep(.tiptap h2) {
font-size: 1.5rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.tiptap-content :deep(.tiptap h3) {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.tiptap-content :deep(.tiptap ul),
.tiptap-content :deep(.tiptap ol) {
padding-left: 1.5rem;
margin-bottom: 1rem;
}
.tiptap-content :deep(.tiptap blockquote) {
border-left: 3px solid #dee2e6;
padding-left: 1rem;
margin-left: 0;
color: #6c757d;
}
.tiptap-content :deep(.tiptap hr) {
border: none;
border-top: 1px solid #dee2e6;
margin: 1.5rem 0;
}
.tiptap-content :deep(.tiptap p) {
margin-bottom: 0.75rem;
}
</style>

View File

@@ -146,13 +146,14 @@
<strong>회의 내용</strong>
</div>
<div class="card-body p-0">
<textarea
<TiptapEditor
v-if="isEditing"
class="form-control border-0 h-100"
v-model="form.rawContent"
style="min-height: 300px; resize: none;"
></textarea>
<div v-else class="p-3" style="min-height: 200px; white-space: pre-wrap;">{{ meeting.rawContent || '(내용 없음)' }}</div>
:show-toolbar="true"
min-height="300px"
placeholder="회의 내용을 입력하세요..."
/>
<div v-else class="p-3 meeting-content" style="min-height: 200px;" v-html="meeting.rawContent || '<span class=\'text-muted\'>(내용 없음)</span>'"></div>
</div>
<div v-if="isEditing" class="card-footer d-flex justify-content-end">
<button class="btn btn-secondary me-2" @click="cancelEdit">취소</button>
@@ -600,4 +601,35 @@ function getStatusText(status: string) {
.modal.show {
background: rgba(0, 0, 0, 0.5);
}
.meeting-content :deep(h2) {
font-size: 1.5rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.meeting-content :deep(h3) {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.meeting-content :deep(ul),
.meeting-content :deep(ol) {
padding-left: 1.5rem;
margin-bottom: 1rem;
}
.meeting-content :deep(blockquote) {
border-left: 3px solid #dee2e6;
padding-left: 1rem;
margin-left: 0;
color: #6c757d;
}
.meeting-content :deep(p) {
margin-bottom: 0.75rem;
}
</style>

View File

@@ -121,20 +121,18 @@
<small class="text-muted ms-2">(자유롭게 작성하면 AI가 정리해줍니다)</small>
</div>
<div class="card-body p-0">
<!-- TODO: Tiptap 에디터로 교체 -->
<textarea
class="form-control border-0 h-100"
<TiptapEditor
v-model="form.rawContent"
:show-toolbar="true"
min-height="500px"
placeholder="회의 내용을 자유롭게 작성하세요...
예시:
- 김철수: 이번 주 진행 상황 공유
- 프론트엔드 개발 80% 완료
- 백엔드 API 연동 필요
- 다음 주 목표: 테스트 환경 구축
- 미결정: 배포 일정 (추후 논의)"
style="min-height: 500px; resize: none;"
></textarea>
- 다음 주 목표: 테스트 환경 구축"
/>
</div>
<div class="card-footer d-flex justify-content-end">
<button class="btn btn-secondary me-2" @click="router.push('/meeting')">취소</button>