feat: 添加批注功能

This commit is contained in:
pipipi-pikachu 2024-01-16 19:51:24 +08:00
parent aeee2b25d2
commit 0d0e729944
9 changed files with 461 additions and 13 deletions

View File

@ -67,6 +67,7 @@ npm run dev
- 元素动画(入场、退场、强调) - 元素动画(入场、退场、强调)
- 选择面板(隐藏元素、层级排序、元素命名) - 选择面板(隐藏元素、层级排序、元素命名)
- 查找/替换 - 查找/替换
- 批注
### 幻灯片元素编辑 ### 幻灯片元素编辑
- 元素添加、删除 - 元素添加、删除
- 元素复制粘贴 - 元素复制粘贴

View File

@ -3,8 +3,8 @@
class="moveable-panel" class="moveable-panel"
ref="moveablePanelRef" ref="moveablePanelRef"
:style="{ :style="{
width: width + 'px', width: w + 'px',
height: height ? height + 'px' : 'auto', height: h ? h + 'px' : 'auto',
left: x + 'px', left: x + 'px',
top: y + 'px', top: y + 'px',
}" }"
@ -23,6 +23,8 @@
<div v-else class="content" @mousedown="$event => startMove($event)"> <div v-else class="content" @mousedown="$event => startMove($event)">
<slot></slot> <slot></slot>
</div> </div>
<div class="resizer" v-if="resizeable" @mousedown="$event => startResize($event)"></div>
</div> </div>
</template> </template>
@ -32,15 +34,25 @@ import { computed, onMounted, ref } from 'vue'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
width: number width: number
height: number height: number
minWidth?: number
minHeight?: number
maxWidth?: number
maxHeight?: number
left?: number left?: number
top?: number top?: number
title?: string title?: string
moveable?: boolean moveable?: boolean
resizeable?: boolean
}>(), { }>(), {
minWidth: 20,
minHeight: 20,
maxWidth: 500,
maxHeight: 500,
left: 10, left: 10,
top: 10, top: 10,
title: '', title: '',
moveable: true, moveable: true,
resizeable: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@ -49,12 +61,14 @@ const emit = defineEmits<{
const x = ref(0) const x = ref(0)
const y = ref(0) const y = ref(0)
const w = ref(0)
const h = ref(0)
const moveablePanelRef = ref<HTMLElement>() const moveablePanelRef = ref<HTMLElement>()
const realHeight = computed(() => { const realHeight = computed(() => {
if (!props.height) { if (!h.value) {
return moveablePanelRef.value?.clientHeight || 0 return moveablePanelRef.value?.clientHeight || 0
} }
return props.height return h.value
}) })
onMounted(() => { onMounted(() => {
@ -63,6 +77,9 @@ onMounted(() => {
if (props.top >= 0) y.value = props.top if (props.top >= 0) y.value = props.top
else y.value = document.body.clientHeight + props.top - realHeight.value else y.value = document.body.clientHeight + props.top - realHeight.value
w.value = props.width
h.value = props.height
}) })
const startMove = (e: MouseEvent) => { const startMove = (e: MouseEvent) => {
@ -90,7 +107,7 @@ const startMove = (e: MouseEvent) => {
if (left < 0) left = 0 if (left < 0) left = 0
if (top < 0) top = 0 if (top < 0) top = 0
if (left + props.width > windowWidth) left = windowWidth - props.width if (left + w.value > windowWidth) left = windowWidth - w.value
if (top + realHeight.value > clientHeight) top = clientHeight - realHeight.value if (top + realHeight.value > clientHeight) top = clientHeight - realHeight.value
x.value = left x.value = left
@ -103,6 +120,42 @@ const startMove = (e: MouseEvent) => {
document.onmouseup = null document.onmouseup = null
} }
} }
const startResize = (e: MouseEvent) => {
if (!props.resizeable) return
let isMouseDown = true
const startPageX = e.pageX
const startPageY = e.pageY
const originWidth = w.value
const originHeight = h.value
document.onmousemove = e => {
if (!isMouseDown) return
const moveX = e.pageX - startPageX
const moveY = e.pageY - startPageY
let width = originWidth + moveX
let height = originHeight + moveY
if (width < props.minWidth) width = props.minWidth
if (height < props.minHeight) height = props.minHeight
if (width > props.maxWidth) width = props.maxWidth
if (height > props.maxHeight) height = props.maxHeight
w.value = width
h.value = height
}
document.onmouseup = () => {
isMouseDown = false
document.onmousemove = null
document.onmouseup = null
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -116,6 +169,27 @@ const startMove = (e: MouseEvent) => {
flex-direction: column; flex-direction: column;
z-index: 999; z-index: 999;
} }
.resizer {
width: 10px;
height: 10px;
position: absolute;
bottom: 0;
right: 0;
cursor: se-resize;
&::after {
content: "";
position: absolute;
bottom: -4px;
right: -4px;
transform: rotate(45deg);
transform-origin: center;
width: 0;
height: 0;
border: 6px solid transparent;
border-left-color: #e1e1e1;
}
}
.header { .header {
height: 40px; height: 40px;
display: flex; display: flex;

View File

@ -10,7 +10,12 @@
:value="value" :value="value"
:rows="rows" :rows="rows"
:placeholder="placeholder" :placeholder="placeholder"
:style="{
padding: padding ? `${padding}px` : '10px',
}"
@input="$event => handleInput($event)" @input="$event => handleInput($event)"
@focus="$event => emit('focus', $event)"
@blur="$event => emit('blur', $event)"
></textarea> ></textarea>
</template> </template>
@ -20,6 +25,7 @@ import { ref } from 'vue'
withDefaults(defineProps<{ withDefaults(defineProps<{
value: string value: string
rows?: number rows?: number
padding?: number
disabled?: boolean disabled?: boolean
resizable?: boolean resizable?: boolean
placeholder?: string placeholder?: string
@ -32,6 +38,8 @@ withDefaults(defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'update:value', payload: string): void (event: 'update:value', payload: string): void
(event: 'focus', payload: FocusEvent): void
(event: 'blur', payload: FocusEvent): void
}>() }>()
const handleInput = (e: Event) => { const handleInput = (e: Event) => {

View File

@ -120,6 +120,8 @@ import {
CheckOne, CheckOne,
CloseOne, CloseOne,
Info, Info,
Comment,
User,
} from '@icon-park/vue-next' } from '@icon-park/vue-next'
export interface Icons { export interface Icons {
@ -245,6 +247,8 @@ export const icons: Icons = {
IconCheckOne: CheckOne, IconCheckOne: CheckOne,
IconCloseOne: CloseOne, IconCloseOne: CloseOne,
IconInfo: Info, IconInfo: Info,
IconComment: Comment,
IconUser: User,
} }
export default { export default {

View File

@ -37,6 +37,7 @@ export interface MainState {
shapeFormatPainter: ShapeFormatPainter | null shapeFormatPainter: ShapeFormatPainter | null
showSelectPanel: boolean showSelectPanel: boolean
showSearchPanel: boolean showSearchPanel: boolean
showNotesPanel: boolean
} }
const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz') const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz')
@ -71,6 +72,7 @@ export const useMainStore = defineStore('main', {
shapeFormatPainter: null, // 形状格式刷 shapeFormatPainter: null, // 形状格式刷
showSelectPanel: false, // 打开选择面板 showSelectPanel: false, // 打开选择面板
showSearchPanel: false, // 打开查找替换面板 showSearchPanel: false, // 打开查找替换面板
showNotesPanel: false, // 打开批注面板
}), }),
getters: { getters: {
@ -196,5 +198,9 @@ export const useMainStore = defineStore('main', {
setSearchPanelState(show: boolean) { setSearchPanelState(show: boolean) {
this.showSearchPanel = show this.showSearchPanel = show
}, },
setNotesPanelState(show: boolean) {
this.showNotesPanel = show
},
}, },
}) })

View File

@ -656,6 +656,22 @@ export interface SlideBackground {
export type TurningMode = 'no' | 'fade' | 'slideX' | 'slideY' | 'random' | 'slideX3D' | 'slideY3D' | 'rotate' | 'scaleY' | 'scaleX' | 'scale' | 'scaleReverse' export type TurningMode = 'no' | 'fade' | 'slideX' | 'slideY' | 'random' | 'slideX3D' | 'slideY3D' | 'rotate' | 'scaleY' | 'scaleX' | 'scale' | 'scaleReverse'
export interface NoteReply {
id: string
content: string
time: number
user: string
}
export interface Note {
id: string
content: string
time: number
user: string
elId?: string
replies?: NoteReply[]
}
/** /**
* *
* *
@ -663,6 +679,8 @@ export type TurningMode = 'no' | 'fade' | 'slideX' | 'slideY' | 'random' | 'slid
* *
* elements: 元素集合 * elements: 元素集合
* *
* notes: 批注
*
* remark?: 备注 * remark?: 备注
* *
* background?: 页面背景 * background?: 页面背景
@ -674,6 +692,7 @@ export type TurningMode = 'no' | 'fade' | 'slideX' | 'slideY' | 'random' | 'slid
export interface Slide { export interface Slide {
id: string id: string
elements: PPTElement[] elements: PPTElement[]
notes: Note[]
remark?: string remark?: string
background?: SlideBackground background?: SlideBackground
animations?: PPTAnimation[] animations?: PPTAnimation[]

View File

@ -3,9 +3,12 @@
<div class="left-handler"> <div class="left-handler">
<IconBack class="handler-item" :class="{ 'disable': !canUndo }" v-tooltip="'撤销'" @click="undo()" /> <IconBack class="handler-item" :class="{ 'disable': !canUndo }" v-tooltip="'撤销'" @click="undo()" />
<IconNext class="handler-item" :class="{ 'disable': !canRedo }" v-tooltip="'重做'" @click="redo()" /> <IconNext class="handler-item" :class="{ 'disable': !canRedo }" v-tooltip="'重做'" @click="redo()" />
<Divider type="vertical" style="height: 20px;" /> <div class="more">
<IconMoveOne class="handler-item" :class="{ 'active': showSelectPanel }" v-tooltip="'选择窗格'" @click="toggleSelectPanel()" /> <Divider type="vertical" style="height: 20px;" />
<IconSearch class="handler-item" :class="{ 'active': showSearchPanel }" v-tooltip="'查找/替换'" @click="toggleSraechPanel()" /> <IconComment class="handler-item" :class="{ 'active': showNotesPanel }" v-tooltip="'批注'" @click="toggleNotesPanel()" />
<IconMoveOne class="handler-item" :class="{ 'active': showSelectPanel }" v-tooltip="'选择窗格'" @click="toggleSelectPanel()" />
<IconSearch class="handler-item" :class="{ 'active': showSearchPanel }" v-tooltip="'查找/替换'" @click="toggleSraechPanel()" />
</div>
</div> </div>
<div class="add-element-handler"> <div class="add-element-handler">
@ -116,7 +119,7 @@ import Popover from '@/components/Popover.vue'
import PopoverMenuItem from '@/components/PopoverMenuItem.vue' import PopoverMenuItem from '@/components/PopoverMenuItem.vue'
const mainStore = useMainStore() const mainStore = useMainStore()
const { creatingElement, creatingCustomShape, showSelectPanel, showSearchPanel } = storeToRefs(mainStore) const { creatingElement, creatingCustomShape, showSelectPanel, showSearchPanel, showNotesPanel } = storeToRefs(mainStore)
const { canUndo, canRedo } = storeToRefs(useSnapshotStore()) const { canUndo, canRedo } = storeToRefs(useSnapshotStore())
const { redo, undo } = useHistorySnapshot() const { redo, undo } = useHistorySnapshot()
@ -199,6 +202,11 @@ const toggleSelectPanel = () => {
const toggleSraechPanel = () => { const toggleSraechPanel = () => {
mainStore.setSearchPanelState(!showSearchPanel.value) mainStore.setSearchPanelState(!showSearchPanel.value)
} }
//
const toggleNotesPanel = () => {
mainStore.setNotesPanelState(!showNotesPanel.value)
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -212,7 +220,7 @@ const toggleSraechPanel = () => {
font-size: 13px; font-size: 13px;
user-select: none; user-select: none;
} }
.left-handler { .left-handler, .more {
display: flex; display: flex;
align-items: center; align-items: center;
} }
@ -311,8 +319,11 @@ const toggleSraechPanel = () => {
} }
} }
@media screen and (width <= 1024px) { @media screen and (width <= 1200px) {
.text { .right-handler .text {
display: none;
}
.more {
display: none; display: none;
} }
} }

View File

@ -0,0 +1,323 @@
<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">
<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" />
<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 = ''"
/>
<div class="footer">
<Button class="btn" v-tooltip="'清空本页批注'" style="flex: 1" @click="clear()"><IconDelete /></Button>
<Button type="primary" class="btn" style="flex: 12" @click="createNote()">添加批注</Button>
</div>
</div>
</div>
</MoveablePanel>
</template>
<script lang="ts" setup>
import { ref, computed } 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 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 = ''
}
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 = ''
}
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: 5px;
display: flex;
.btn + .btn {
margin-left: 5px;
flex-shrink: 0;
}
}
}
</style>

View File

@ -18,6 +18,7 @@
<SelectPanel v-if="showSelectPanel" /> <SelectPanel v-if="showSelectPanel" />
<SearchPanel v-if="showSearchPanel" /> <SearchPanel v-if="showSearchPanel" />
<NotesPanel v-if="showNotesPanel" />
<Modal <Modal
:visible="!!dialogForExport" :visible="!!dialogForExport"
@ -44,10 +45,11 @@ import Remark from './Remark/index.vue'
import ExportDialog from './ExportDialog/index.vue' import ExportDialog from './ExportDialog/index.vue'
import SelectPanel from './SelectPanel.vue' import SelectPanel from './SelectPanel.vue'
import SearchPanel from './SearchPanel.vue' import SearchPanel from './SearchPanel.vue'
import NotesPanel from './NotesPanel.vue'
import Modal from '@/components/Modal.vue' import Modal from '@/components/Modal.vue'
const mainStore = useMainStore() const mainStore = useMainStore()
const { dialogForExport, showSelectPanel, showSearchPanel } = storeToRefs(mainStore) const { dialogForExport, showSelectPanel, showSearchPanel, showNotesPanel } = storeToRefs(mainStore)
const closeExportDialog = () => mainStore.setDialogForExport('') const closeExportDialog = () => mainStore.setDialogForExport('')
const remarkHeight = ref(40) const remarkHeight = ref(40)