mirror of
https://github.com/pipipi-pikachu/PPTist.git
synced 2025-04-15 02:20:00 +08:00
feat: 幻灯片分节
This commit is contained in:
parent
c8547cc898
commit
074ca6745c
@ -54,6 +54,7 @@ npm run dev
|
||||
- Add/delete pages
|
||||
- Copy/paste pages
|
||||
- Adjust page order
|
||||
- Create sections
|
||||
- Background settings (solid color, gradient, image)
|
||||
- Set canvas size
|
||||
- Gridlines
|
||||
|
@ -40,6 +40,7 @@ npm run dev
|
||||
- 页面添加、删除
|
||||
- 页面顺序调整
|
||||
- 页面复制粘贴
|
||||
- 幻灯片分节
|
||||
- 背景设置(纯色、渐变、图片)
|
||||
- 设置画布尺寸
|
||||
- 网格线
|
||||
|
@ -142,7 +142,25 @@ export default () => {
|
||||
const sortSlides = (newIndex: number, oldIndex: number) => {
|
||||
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]
|
||||
_slides.splice(oldIndex, 1)
|
||||
_slides.splice(newIndex, 0, _slide)
|
||||
@ -150,6 +168,77 @@ export default () => {
|
||||
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 {
|
||||
resetSlides,
|
||||
updateSlideIndex,
|
||||
@ -162,5 +251,10 @@ export default () => {
|
||||
cutSlide,
|
||||
selectAllSlide,
|
||||
sortSlides,
|
||||
createSection,
|
||||
removeSection,
|
||||
removeAllSection,
|
||||
removeSectionSlides,
|
||||
updateSectionTitle,
|
||||
}
|
||||
}
|
@ -6,7 +6,7 @@ import { slides } from '@/mocks/slides'
|
||||
import { theme } from '@/mocks/theme'
|
||||
import { layouts } from '@/mocks/layout'
|
||||
|
||||
interface RemoveElementPropData {
|
||||
interface RemovePropData {
|
||||
id: string
|
||||
propName: string | string[]
|
||||
}
|
||||
@ -126,31 +126,56 @@ export const useSlidesStore = defineStore('slides', {
|
||||
|
||||
addSlide(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
|
||||
this.slides.splice(addIndex, 0, ...slides)
|
||||
this.slideIndex = addIndex
|
||||
},
|
||||
|
||||
updateSlide(props: Partial<Slide>) {
|
||||
const slideIndex = this.slideIndex
|
||||
updateSlide(props: Partial<Slide>, slideId?: string) {
|
||||
const slideIndex = slideId ? this.slides.findIndex(item => item.id === slideId) : this.slideIndex
|
||||
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[]) {
|
||||
const slidesId = Array.isArray(slideId) ? slideId : [slideId]
|
||||
const slides: Slide[] = JSON.parse(JSON.stringify(this.slides))
|
||||
|
||||
const deleteSlidesIndex = []
|
||||
for (let i = 0; i < slidesId.length; i++) {
|
||||
const index = this.slides.findIndex(item => item.id === slidesId[i])
|
||||
for (const deletedId of slidesId) {
|
||||
const index = slides.findIndex(item => item.id === deletedId)
|
||||
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)
|
||||
|
||||
const maxIndex = this.slides.length - slidesId.length - 1
|
||||
const maxIndex = slides.length - 1
|
||||
if (newIndex > maxIndex) newIndex = maxIndex
|
||||
|
||||
this.slideIndex = newIndex
|
||||
this.slides = this.slides.filter(item => !slidesId.includes(item.id))
|
||||
this.slides = slides
|
||||
},
|
||||
|
||||
updateSlideIndex(index: number) {
|
||||
@ -183,7 +208,7 @@ export const useSlidesStore = defineStore('slides', {
|
||||
this.slides[slideIndex].elements = (elements as PPTElement[])
|
||||
},
|
||||
|
||||
removeElementProps(data: RemoveElementPropData) {
|
||||
removeElementProps(data: RemovePropData) {
|
||||
const { id, propName } = data
|
||||
const propsNames = typeof propName === 'string' ? [propName] : propName
|
||||
|
||||
|
@ -677,6 +677,11 @@ export interface Note {
|
||||
replies?: NoteReply[]
|
||||
}
|
||||
|
||||
export interface SectionTag {
|
||||
id: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 幻灯片页面
|
||||
*
|
||||
@ -702,6 +707,7 @@ export interface Slide {
|
||||
background?: SlideBackground
|
||||
animations?: PPTAnimation[]
|
||||
turningMode?: TurningMode
|
||||
sectionTag?: SectionTag
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -34,7 +34,7 @@
|
||||
@dblclick="enterEdit(groupItem.id)"
|
||||
>
|
||||
<input
|
||||
:id="`input-${groupItem.id}`"
|
||||
:id="`select-panel-input-${groupItem.id}`"
|
||||
:value="groupItem.name || ELEMENT_TYPE_ZH[groupItem.type]"
|
||||
class="input"
|
||||
type="text"
|
||||
@ -145,7 +145,7 @@ const saveElementName = (e: FocusEvent | KeyboardEvent, id: string) => {
|
||||
const enterEdit = (id: string) => {
|
||||
editingElId.value = id
|
||||
nextTick(() => {
|
||||
const inputRef = document.querySelector(`#input-${id}`) as HTMLInputElement
|
||||
const inputRef = document.querySelector(`#select-panel-input-${id}`) as HTMLInputElement
|
||||
inputRef.focus()
|
||||
})
|
||||
}
|
||||
|
@ -26,20 +26,38 @@
|
||||
itemKey="id"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<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 class="thumbnail-container">
|
||||
<div class="section-title"
|
||||
:data-section-id="element?.sectionTag?.id || ''"
|
||||
v-if="element.sectionTag || (hasSection && index === 0)"
|
||||
v-contextmenu="contextmenusSection"
|
||||
>
|
||||
<input
|
||||
:id="`section-title-input-${element?.sectionTag?.id || 'default'}`"
|
||||
type="text"
|
||||
:value="element?.sectionTag?.title || ''"
|
||||
placeholder="输入节名称"
|
||||
@blur="$event => saveSection($event)"
|
||||
@keydown.enter.stop="$event => saveSection($event)"
|
||||
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>
|
||||
</template>
|
||||
</Draggable>
|
||||
@ -68,7 +86,7 @@ const mainStore = useMainStore()
|
||||
const slidesStore = useSlidesStore()
|
||||
const keyboardStore = useKeyboardStore()
|
||||
const { selectedSlidesIndex: _selectedSlidesIndex, thumbnailsFocus } = storeToRefs(mainStore)
|
||||
const { slides, slideIndex } = storeToRefs(slidesStore)
|
||||
const { slides, slideIndex, currentSlide } = storeToRefs(slidesStore)
|
||||
const { ctrlKeyState, shiftKeyState } = storeToRefs(keyboardStore)
|
||||
|
||||
const { slidesLoadLimit } = useLoadSlides()
|
||||
@ -77,6 +95,10 @@ const selectedSlidesIndex = computed(() => [..._selectedSlidesIndex.value, slide
|
||||
|
||||
const presetLayoutPopoverVisible = ref(false)
|
||||
|
||||
const hasSection = computed(() => {
|
||||
return slides.value.some(item => item.sectionTag)
|
||||
})
|
||||
|
||||
const {
|
||||
copySlide,
|
||||
pasteSlide,
|
||||
@ -87,6 +109,11 @@ const {
|
||||
cutSlide,
|
||||
selectAllSlide,
|
||||
sortSlides,
|
||||
createSection,
|
||||
removeSection,
|
||||
removeAllSection,
|
||||
removeSectionSlides,
|
||||
updateSectionTitle,
|
||||
} = useSlideHandler()
|
||||
|
||||
// 页面被切换时
|
||||
@ -187,6 +214,49 @@ const openNotesPanel = () => {
|
||||
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 contextmenusThumbnails = (): ContextmenuItem[] => {
|
||||
@ -252,6 +322,11 @@ const contextmenusThumbnailItem = (): ContextmenuItem[] => {
|
||||
subText: 'Delete',
|
||||
handler: () => deleteSlide(),
|
||||
},
|
||||
{
|
||||
text: '增加节',
|
||||
handler: createSection,
|
||||
disable: !!currentSlide.value.sectionTag,
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
text: '从当前放映',
|
||||
@ -334,20 +409,27 @@ const contextmenusThumbnailItem = (): ContextmenuItem[] => {
|
||||
.thumbnail {
|
||||
outline-color: $themeColor;
|
||||
}
|
||||
.note-flag {
|
||||
background-color: $themeColor;
|
||||
|
||||
&::after {
|
||||
border-top-color: $themeColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.note-flag {
|
||||
width: 18px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
border-radius: 1px;
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 10px;
|
||||
left: 8px;
|
||||
top: 13px;
|
||||
font-size: 8px;
|
||||
background-color: $themeColor;
|
||||
background-color: rgba($color: $themeColor, $alpha: .75);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
line-height: 14px;
|
||||
line-height: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&::after {
|
||||
@ -355,10 +437,10 @@ const contextmenusThumbnailItem = (): ContextmenuItem[] => {
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
left: 5px;
|
||||
top: 10px;
|
||||
left: 4px;
|
||||
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;
|
||||
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>
|
Loading…
x
Reference in New Issue
Block a user