feat: 幻灯片分节

This commit is contained in:
zxc 2024-07-28 12:50:29 +08:00
parent c8547cc898
commit 074ca6745c
7 changed files with 288 additions and 36 deletions

View File

@ -54,6 +54,7 @@ npm run dev
- Add/delete pages - Add/delete pages
- Copy/paste pages - Copy/paste pages
- Adjust page order - Adjust page order
- Create sections
- Background settings (solid color, gradient, image) - Background settings (solid color, gradient, image)
- Set canvas size - Set canvas size
- Gridlines - Gridlines

View File

@ -40,6 +40,7 @@ npm run dev
- 页面添加、删除 - 页面添加、删除
- 页面顺序调整 - 页面顺序调整
- 页面复制粘贴 - 页面复制粘贴
- 幻灯片分节
- 背景设置(纯色、渐变、图片) - 背景设置(纯色、渐变、图片)
- 设置画布尺寸 - 设置画布尺寸
- 网格线 - 网格线

View File

@ -142,7 +142,25 @@ export default () => {
const sortSlides = (newIndex: number, oldIndex: number) => { const sortSlides = (newIndex: number, oldIndex: number) => {
if (oldIndex === newIndex) return if (oldIndex === newIndex) return
const _slides = JSON.parse(JSON.stringify(slides.value)) const _slides: Slide[] = JSON.parse(JSON.stringify(slides.value))
const movingSlide = _slides[oldIndex]
const movingSlideSection = movingSlide.sectionTag
if (movingSlideSection) {
const movingSlideSectionNext = _slides[oldIndex + 1]
delete movingSlide.sectionTag
if (movingSlideSectionNext && !movingSlideSectionNext.sectionTag) {
movingSlideSectionNext.sectionTag = movingSlideSection
}
}
if (newIndex === 0) {
const firstSection = _slides[0].sectionTag
if (firstSection) {
delete _slides[0].sectionTag
movingSlide.sectionTag = firstSection
}
}
const _slide = _slides[oldIndex] const _slide = _slides[oldIndex]
_slides.splice(oldIndex, 1) _slides.splice(oldIndex, 1)
_slides.splice(newIndex, 0, _slide) _slides.splice(newIndex, 0, _slide)
@ -150,6 +168,77 @@ export default () => {
slidesStore.updateSlideIndex(newIndex) slidesStore.updateSlideIndex(newIndex)
} }
const createSection = () => {
slidesStore.updateSlide({
sectionTag: {
id: nanoid(6),
},
})
addHistorySnapshot()
}
const removeSection = (sectionId: string) => {
if (!sectionId) return
const slide = slides.value.find(slide => slide.sectionTag?.id === sectionId)!
slidesStore.removeSlideProps({
id: slide.id,
propName: 'sectionTag',
})
addHistorySnapshot()
}
const removeAllSection = () => {
const _slides = slides.value.map(slide => {
if (slide.sectionTag) delete slide.sectionTag
return slide
})
slidesStore.setSlides(_slides)
addHistorySnapshot()
}
const removeSectionSlides = (sectionId: string) => {
let startIndex = 0
if (sectionId) {
startIndex = slides.value.findIndex(slide => slide.sectionTag?.id === sectionId)
}
const ids: string[] = []
for (let i = startIndex; i < slides.value.length; i++) {
const slide = slides.value[i]
if(i !== startIndex && slide.sectionTag) break
ids.push(slide.id)
}
deleteSlide(ids)
}
const updateSectionTitle = (sectionId: string, title: string) => {
if (!title) return
if (sectionId === 'default') {
slidesStore.updateSlide({
sectionTag: {
id: nanoid(6),
title,
},
}, slides.value[0].id)
}
else {
const slide = slides.value.find(slide => slide.sectionTag?.id === sectionId)
if (!slide) return
slidesStore.updateSlide({
sectionTag: {
...slide.sectionTag!,
title,
},
}, slide.id)
}
addHistorySnapshot()
}
return { return {
resetSlides, resetSlides,
updateSlideIndex, updateSlideIndex,
@ -162,5 +251,10 @@ export default () => {
cutSlide, cutSlide,
selectAllSlide, selectAllSlide,
sortSlides, sortSlides,
createSection,
removeSection,
removeAllSection,
removeSectionSlides,
updateSectionTitle,
} }
} }

View File

@ -6,7 +6,7 @@ import { slides } from '@/mocks/slides'
import { theme } from '@/mocks/theme' import { theme } from '@/mocks/theme'
import { layouts } from '@/mocks/layout' import { layouts } from '@/mocks/layout'
interface RemoveElementPropData { interface RemovePropData {
id: string id: string
propName: string | string[] propName: string | string[]
} }
@ -126,31 +126,56 @@ export const useSlidesStore = defineStore('slides', {
addSlide(slide: Slide | Slide[]) { addSlide(slide: Slide | Slide[]) {
const slides = Array.isArray(slide) ? slide : [slide] const slides = Array.isArray(slide) ? slide : [slide]
for (const slide of slides) {
if (slide.sectionTag) delete slide.sectionTag
}
const addIndex = this.slideIndex + 1 const addIndex = this.slideIndex + 1
this.slides.splice(addIndex, 0, ...slides) this.slides.splice(addIndex, 0, ...slides)
this.slideIndex = addIndex this.slideIndex = addIndex
}, },
updateSlide(props: Partial<Slide>) { updateSlide(props: Partial<Slide>, slideId?: string) {
const slideIndex = this.slideIndex const slideIndex = slideId ? this.slides.findIndex(item => item.id === slideId) : this.slideIndex
this.slides[slideIndex] = { ...this.slides[slideIndex], ...props } this.slides[slideIndex] = { ...this.slides[slideIndex], ...props }
}, },
removeSlideProps(data: RemovePropData) {
const { id, propName } = data
const slides = this.slides.map(slide => {
return slide.id === id ? omit(slide, propName) : slide
}) as Slide[]
this.slides = slides
},
deleteSlide(slideId: string | string[]) { deleteSlide(slideId: string | string[]) {
const slidesId = Array.isArray(slideId) ? slideId : [slideId] const slidesId = Array.isArray(slideId) ? slideId : [slideId]
const slides: Slide[] = JSON.parse(JSON.stringify(this.slides))
const deleteSlidesIndex = [] const deleteSlidesIndex = []
for (let i = 0; i < slidesId.length; i++) { for (const deletedId of slidesId) {
const index = this.slides.findIndex(item => item.id === slidesId[i]) const index = slides.findIndex(item => item.id === deletedId)
deleteSlidesIndex.push(index) deleteSlidesIndex.push(index)
const deletedSlideSection = slides[index].sectionTag
if(deletedSlideSection) {
const handleSlideNext = slides[index + 1]
if(handleSlideNext && !handleSlideNext.sectionTag) {
delete slides[index].sectionTag
slides[index + 1].sectionTag = deletedSlideSection
}
}
slides.splice(index, 1)
} }
let newIndex = Math.min(...deleteSlidesIndex) let newIndex = Math.min(...deleteSlidesIndex)
const maxIndex = this.slides.length - slidesId.length - 1 const maxIndex = slides.length - 1
if (newIndex > maxIndex) newIndex = maxIndex if (newIndex > maxIndex) newIndex = maxIndex
this.slideIndex = newIndex this.slideIndex = newIndex
this.slides = this.slides.filter(item => !slidesId.includes(item.id)) this.slides = slides
}, },
updateSlideIndex(index: number) { updateSlideIndex(index: number) {
@ -183,7 +208,7 @@ export const useSlidesStore = defineStore('slides', {
this.slides[slideIndex].elements = (elements as PPTElement[]) this.slides[slideIndex].elements = (elements as PPTElement[])
}, },
removeElementProps(data: RemoveElementPropData) { removeElementProps(data: RemovePropData) {
const { id, propName } = data const { id, propName } = data
const propsNames = typeof propName === 'string' ? [propName] : propName const propsNames = typeof propName === 'string' ? [propName] : propName

View File

@ -677,6 +677,11 @@ export interface Note {
replies?: NoteReply[] replies?: NoteReply[]
} }
export interface SectionTag {
id: string
title?: string
}
/** /**
* *
* *
@ -702,6 +707,7 @@ export interface Slide {
background?: SlideBackground background?: SlideBackground
animations?: PPTAnimation[] animations?: PPTAnimation[]
turningMode?: TurningMode turningMode?: TurningMode
sectionTag?: SectionTag
} }
/** /**

View File

@ -34,7 +34,7 @@
@dblclick="enterEdit(groupItem.id)" @dblclick="enterEdit(groupItem.id)"
> >
<input <input
:id="`input-${groupItem.id}`" :id="`select-panel-input-${groupItem.id}`"
:value="groupItem.name || ELEMENT_TYPE_ZH[groupItem.type]" :value="groupItem.name || ELEMENT_TYPE_ZH[groupItem.type]"
class="input" class="input"
type="text" type="text"
@ -145,7 +145,7 @@ const saveElementName = (e: FocusEvent | KeyboardEvent, id: string) => {
const enterEdit = (id: string) => { const enterEdit = (id: string) => {
editingElId.value = id editingElId.value = id
nextTick(() => { nextTick(() => {
const inputRef = document.querySelector(`#input-${id}`) as HTMLInputElement const inputRef = document.querySelector(`#select-panel-input-${id}`) as HTMLInputElement
inputRef.focus() inputRef.focus()
}) })
} }

View File

@ -26,20 +26,38 @@
itemKey="id" itemKey="id"
> >
<template #item="{ element, index }"> <template #item="{ element, index }">
<div <div class="thumbnail-container">
class="thumbnail-item" <div class="section-title"
:class="{ :data-section-id="element?.sectionTag?.id || ''"
'active': slideIndex === index, v-if="element.sectionTag || (hasSection && index === 0)"
'selected': selectedSlidesIndex.includes(index), v-contextmenu="contextmenusSection"
}" >
@mousedown="$event => handleClickSlideThumbnail($event, index)" <input
@dblclick="enterScreening()" :id="`section-title-input-${element?.sectionTag?.id || 'default'}`"
v-contextmenu="contextmenusThumbnailItem" type="text"
> :value="element?.sectionTag?.title || ''"
<div class="label" :class="{ 'offset-left': index >= 99 }">{{ fillDigit(index + 1, 2) }}</div> placeholder="输入节名称"
<ThumbnailSlide class="thumbnail" :slide="element" :size="120" :visible="index < slidesLoadLimit" /> @blur="$event => saveSection($event)"
@keydown.enter.stop="$event => saveSection($event)"
<div class="note-flag" v-if="element.notes && element.notes.length" @click="openNotesPanel()">{{ element.notes.length }}</div> v-if="editingSectionId === element?.sectionTag?.id || (index === 0 && editingSectionId === 'default')"
>
<span class="text" v-else>{{ element?.sectionTag ? (element?.sectionTag?.title || '无标题节') : '默认节' }}</span>
</div>
<div
class="thumbnail-item"
:class="{
'active': slideIndex === index,
'selected': selectedSlidesIndex.includes(index),
}"
@mousedown="$event => handleClickSlideThumbnail($event, index)"
@dblclick="enterScreening()"
v-contextmenu="contextmenusThumbnailItem"
>
<div class="label" :class="{ 'offset-left': index >= 99 }">{{ fillDigit(index + 1, 2) }}</div>
<ThumbnailSlide class="thumbnail" :slide="element" :size="120" :visible="index < slidesLoadLimit" />
<div class="note-flag" v-if="element.notes && element.notes.length" @click="openNotesPanel()">{{ element.notes.length }}</div>
</div>
</div> </div>
</template> </template>
</Draggable> </Draggable>
@ -68,7 +86,7 @@ const mainStore = useMainStore()
const slidesStore = useSlidesStore() const slidesStore = useSlidesStore()
const keyboardStore = useKeyboardStore() const keyboardStore = useKeyboardStore()
const { selectedSlidesIndex: _selectedSlidesIndex, thumbnailsFocus } = storeToRefs(mainStore) const { selectedSlidesIndex: _selectedSlidesIndex, thumbnailsFocus } = storeToRefs(mainStore)
const { slides, slideIndex } = storeToRefs(slidesStore) const { slides, slideIndex, currentSlide } = storeToRefs(slidesStore)
const { ctrlKeyState, shiftKeyState } = storeToRefs(keyboardStore) const { ctrlKeyState, shiftKeyState } = storeToRefs(keyboardStore)
const { slidesLoadLimit } = useLoadSlides() const { slidesLoadLimit } = useLoadSlides()
@ -77,6 +95,10 @@ const selectedSlidesIndex = computed(() => [..._selectedSlidesIndex.value, slide
const presetLayoutPopoverVisible = ref(false) const presetLayoutPopoverVisible = ref(false)
const hasSection = computed(() => {
return slides.value.some(item => item.sectionTag)
})
const { const {
copySlide, copySlide,
pasteSlide, pasteSlide,
@ -87,6 +109,11 @@ const {
cutSlide, cutSlide,
selectAllSlide, selectAllSlide,
sortSlides, sortSlides,
createSection,
removeSection,
removeAllSection,
removeSectionSlides,
updateSectionTitle,
} = useSlideHandler() } = useSlideHandler()
// //
@ -187,6 +214,49 @@ const openNotesPanel = () => {
mainStore.setNotesPanelState(true) mainStore.setNotesPanelState(true)
} }
const editingSectionId = ref('')
const editSection = (id: string) => {
mainStore.setDisableHotkeysState(true)
editingSectionId.value = id || 'default'
nextTick(() => {
const inputRef = document.querySelector(`#section-title-input-${id || 'default'}`) as HTMLInputElement
inputRef.focus()
})
}
const saveSection = (e: FocusEvent | KeyboardEvent) => {
const title = (e.target as HTMLInputElement).value
updateSectionTitle(editingSectionId.value, title)
editingSectionId.value = ''
mainStore.setDisableHotkeysState(false)
}
const contextmenusSection = (el: HTMLElement): ContextmenuItem[] => {
const sectionId = el.dataset.sectionId!
return [
{
text: '删除节',
handler: () => removeSection(sectionId),
},
{
text: '删除节和幻灯片',
handler: () => removeSectionSlides(sectionId),
},
{
text: '删除所有节',
handler: removeAllSection,
},
{
text: '重命名节',
handler: () => editSection(sectionId),
},
]
}
const { enterScreening, enterScreeningFromStart } = useScreening() const { enterScreening, enterScreeningFromStart } = useScreening()
const contextmenusThumbnails = (): ContextmenuItem[] => { const contextmenusThumbnails = (): ContextmenuItem[] => {
@ -252,6 +322,11 @@ const contextmenusThumbnailItem = (): ContextmenuItem[] => {
subText: 'Delete', subText: 'Delete',
handler: () => deleteSlide(), handler: () => deleteSlide(),
}, },
{
text: '增加节',
handler: createSection,
disable: !!currentSlide.value.sectionTag,
},
{ divider: true }, { divider: true },
{ {
text: '从当前放映', text: '从当前放映',
@ -334,20 +409,27 @@ const contextmenusThumbnailItem = (): ContextmenuItem[] => {
.thumbnail { .thumbnail {
outline-color: $themeColor; outline-color: $themeColor;
} }
.note-flag {
background-color: $themeColor;
&::after {
border-top-color: $themeColor;
}
}
} }
.note-flag { .note-flag {
width: 18px; width: 16px;
height: 14px; height: 12px;
border-radius: 2px; border-radius: 1px;
position: absolute; position: absolute;
left: 7px; left: 8px;
top: 10px; top: 13px;
font-size: 8px; font-size: 8px;
background-color: $themeColor; background-color: rgba($color: $themeColor, $alpha: .75);
color: #fff; color: #fff;
text-align: center; text-align: center;
line-height: 14px; line-height: 12px;
cursor: pointer; cursor: pointer;
&::after { &::after {
@ -355,10 +437,10 @@ const contextmenusThumbnailItem = (): ContextmenuItem[] => {
width: 0; width: 0;
height: 0; height: 0;
position: absolute; position: absolute;
top: 13px; top: 10px;
left: 5px; left: 4px;
border: 4px solid transparent; border: 4px solid transparent;
border-top-color: $themeColor; border-top-color: rgba($color: $themeColor, $alpha: .75);
} }
} }
} }
@ -385,4 +467,47 @@ const contextmenusThumbnailItem = (): ContextmenuItem[] => {
text-align: center; text-align: center;
color: #666; color: #666;
} }
.section-title {
height: 26px;
font-size: 12px;
padding: 6px 8px 2px 18px;
color: #555;
&.contextmenu-active {
color: $themeColor;
.text::before {
border-bottom-color: $themeColor;
border-right-color: $themeColor;
}
}
.text {
width: 100%;
display: inline-block;
display: flex;
align-items: center;
position: relative;
@include ellipsis-oneline();
&::before {
content: '';
width: 0;
height: 0;
border-top: 3px solid transparent;
border-left: 3px solid transparent;
border-bottom: 3px solid #555;
border-right: 3px solid #555;
margin-right: 5px;
}
}
input {
width: 100%;
border: 0;
outline: 0;
padding: 0;
font-size: 12px;
}
}
</style> </style>