web_ppt / frontend /src /views /Editor /NotesPanel.vue
CatPtain's picture
Upload 339 files
89ce340 verified
raw
history blame
8.65 kB
<template>
<MoveablePanel
class="notes-panel"
:width="300"
:height="560"
:title="`幻灯片${slideIndex + 1}的批注`"
:left="-270"
:top="90"
:minWidth="300"
:minHeight="400"
:maxWidth="480"
:maxHeight="780"
resizeable
@close="close()"
>
<div class="container">
<div class="notes" ref="notesRef">
<div class="note" :class="{ 'active': activeNoteId === note.id }" v-for="note in notes" :key="note.id" @click="handleClickNote(note)">
<div class="header note-header">
<div class="user">
<div class="avatar"><IconUser /></div>
<div class="user-info">
<div class="username">{{ note.user }}</div>
<div class="time">{{ new Date(note.time).toLocaleString() }}</div>
</div>
</div>
<div class="btns">
<div class="btn reply" @click="replyNoteId = note.id">回复</div>
<div class="btn delete" @click.stop="deleteNote(note.id)">删除</div>
</div>
</div>
<div class="content">{{ note.content }}</div>
<div class="replies" v-if="note.replies?.length">
<div class="reply-item" v-for="reply in note.replies" :key="reply.id">
<div class="header reply-header">
<div class="user">
<div class="avatar"><IconUser /></div>
<div class="user-info">
<div class="username">{{ reply.user }}</div>
<div class="time">{{ new Date(reply.time).toLocaleString() }}</div>
</div>
</div>
<div class="btns">
<div class="btn delete" @click.stop="deleteReply(note.id, reply.id)">删除</div>
</div>
</div>
<div class="content">{{ reply.content }}</div>
</div>
</div>
<div class="note-reply" v-if="replyNoteId === note.id">
<TextArea :padding="6" v-model:value="replyContent" placeholder="输入回复内容" :rows="1" @enter.prevent="createNoteReply()" />
<div class="reply-btns">
<Button class="btn" size="small" @click="replyNoteId = ''">取消</Button>
<Button class="btn" size="small" type="primary" @click="createNoteReply()">回复</Button>
</div>
</div>
</div>
<div class="empty" v-if="!notes.length">本页暂无批注</div>
</div>
<div class="send">
<TextArea
ref="textAreaRef"
v-model:value="content"
:padding="6"
:placeholder="`输入批注(为${handleElementId ? '选中元素' : '当前页幻灯片' })`"
:rows="2"
@focus="replyNoteId = ''; activeNoteId = ''"
@enter.prevent="createNote()"
/>
<div class="footer">
<IconDelete class="btn icon" v-tooltip="'清空本页批注'" style="flex: 1" @click="clear()" />
<Button type="primary" class="btn" style="flex: 12" @click="createNote()">添加批注</Button>
</div>
</div>
</div>
</MoveablePanel>
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick } from 'vue'
import { storeToRefs } from 'pinia'
import { nanoid } from 'nanoid'
import { useMainStore, useSlidesStore } from '@/store'
import type { Note } from '@/types/slides'
import MoveablePanel from '@/components/MoveablePanel.vue'
import TextArea from '@/components/TextArea.vue'
import Button from '@/components/Button.vue'
const slidesStore = useSlidesStore()
const mainStore = useMainStore()
const { slideIndex, currentSlide } = storeToRefs(slidesStore)
const { handleElementId } = storeToRefs(mainStore)
const content = ref('')
const replyContent = ref('')
const notes = computed(() => currentSlide.value?.notes || [])
const activeNoteId = ref('')
const replyNoteId = ref('')
const textAreaRef = ref<InstanceType<typeof TextArea>>()
const notesRef = ref<HTMLElement>()
watch(slideIndex, () => {
activeNoteId.value = ''
replyNoteId.value = ''
})
const scrollToBottom = () => {
if (notesRef.value) {
notesRef.value.scrollTop = notesRef.value.scrollHeight
}
}
const createNote = () => {
if (!content.value) {
if (textAreaRef.value) textAreaRef.value.focus()
return
}
const newNote: Note = {
id: nanoid(),
content: content.value,
time: new Date().getTime(),
user: '测试用户',
}
if (handleElementId.value) newNote.elId = handleElementId.value
const newNotes = [
...notes.value,
newNote,
]
slidesStore.updateSlide({ notes: newNotes })
content.value = ''
nextTick(scrollToBottom)
}
const deleteNote = (id: string) => {
const newNotes = notes.value.filter(note => note.id !== id)
slidesStore.updateSlide({ notes: newNotes })
}
const createNoteReply = () => {
if (!replyContent.value) return
const currentNote = notes.value.find(note => note.id === replyNoteId.value)
if (!currentNote) return
const newReplies = [
...currentNote.replies || [],
{
id: nanoid(),
content: replyContent.value,
time: new Date().getTime(),
user: '测试用户',
},
]
const newNote: Note = {
...currentNote,
replies: newReplies,
}
const newNotes = notes.value.map(note => note.id === replyNoteId.value ? newNote : note)
slidesStore.updateSlide({ notes: newNotes })
replyContent.value = ''
replyNoteId.value = ''
nextTick(scrollToBottom)
}
const deleteReply = (noteId: string, replyId: string) => {
const currentNote = notes.value.find(note => note.id === noteId)
if (!currentNote || !currentNote.replies) return
const newReplies = currentNote.replies.filter(reply => reply.id !== replyId)
const newNote: Note = {
...currentNote,
replies: newReplies,
}
const newNotes = notes.value.map(note => note.id === noteId ? newNote : note)
slidesStore.updateSlide({ notes: newNotes })
}
const handleClickNote = (note: Note) => {
activeNoteId.value = note.id
if (note.elId) {
const elIds = currentSlide.value.elements.map(item => item.id)
if (elIds.includes(note.elId)) {
mainStore.setActiveElementIdList([note.elId])
}
else mainStore.setActiveElementIdList([])
}
else mainStore.setActiveElementIdList([])
}
const clear = () => {
slidesStore.updateSlide({ notes: [] })
}
const close = () => {
mainStore.setNotesPanelState(false)
}
</script>
<style lang="scss" scoped>
.notes-panel {
height: 100%;
font-size: 12px;
user-select: none;
}
.container {
height: 100%;
display: flex;
flex-direction: column;
}
.notes {
flex: 1;
overflow: auto;
margin: 0 -10px;
padding: 2px 12px;
}
.empty {
width: 100%;
height: 100%;
color: #999;
font-style: italic;
display: flex;
justify-content: center;
align-items: center;
}
.note {
border: 1px solid #eee;
border-radius: 4px;
padding: 10px;
& + .note {
margin-top: 10px;
}
&.active {
background-color: #f7f7f7;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
&:hover {
.btns {
opacity: 1;
}
}
}
.user {
display: flex;
align-items: center;
.avatar {
width: 30px;
height: 30px;
border-radius: 50%;
background-color: #42ba97;
color: #fff;
font-size: 18px;
display: flex;
justify-content: center;
align-items: center;
margin-right: 10px;
}
.username {
font-size: 14px;
}
.time {
font-size: 12px;
color: #aaa;
}
}
.btns {
display: flex;
align-items: center;
opacity: 0;
.btn {
margin-left: 5px;
cursor: pointer;
&:hover {
text-decoration: underline;
color: $themeColor;
}
}
}
.replies {
margin-left: 20px;
margin-top: 15px;
.reply-item {
margin-top: 10px;
.content {
margin-top: 5px;
}
}
}
}
.note-reply {
margin-top: 15px;
}
.reply-btns {
margin-top: 5px;
text-align: right;
.btn {
margin-left: 8px;
}
}
.send {
height: 120px;
flex-shrink: 0;
text-align: right;
display: flex;
flex-direction: column;
justify-content: flex-end;
.footer {
margin-top: 10px;
display: flex;
.btn {
display: flex;
justify-content: center;
align-items: center;
&.icon {
font-size: 18px;
color: #666;
cursor: pointer;
}
}
.btn + .btn {
margin-left: 8px;
}
}
}
</style>