feat: 添加AIPPT大纲编辑面板

This commit is contained in:
pipipi-pikachu 2025-02-21 21:44:32 +08:00
parent 1f5ca42a6d
commit 3c2830b12b
3 changed files with 248 additions and 17 deletions

View File

@ -22,6 +22,7 @@
@blur="$event => handleBlur($event)" @blur="$event => handleBlur($event)"
@change="$event => emit('change', $event)" @change="$event => emit('change', $event)"
@keydown.enter="$event => emit('enter', $event)" @keydown.enter="$event => emit('enter', $event)"
@keydown.backspace="$event => emit('backspace', $event)"
/> />
<span class="suffix"> <span class="suffix">
<slot name="suffix"></slot> <slot name="suffix"></slot>
@ -51,6 +52,7 @@ const emit = defineEmits<{
(event: 'blur', payload: Event): void (event: 'blur', payload: Event): void
(event: 'focus', payload: Event): void (event: 'focus', payload: Event): void
(event: 'enter', payload: Event): void (event: 'enter', payload: Event): void
(event: 'backspace', payload: Event): void
}>() }>()
const focused = ref(false) const focused = ref(false)

View File

@ -1,32 +1,64 @@
<template> <template>
<div class="outline-editor"> <div class="outline-editor">
<div class="item" :class="[{ 'title': item.title }, `lv-${item.lv}`]" :style="{ marginLeft: 20 * item.lv + 'px' }" v-for="item in data" :key="item.id"> <div class="item"
<Input ref="editableRef" class="editable-text" :value="item.content" v-if="activeItemId === item.id" @blur="activeItemId = ''" /> :class="[{ 'title': item.title }, `lv-${item.lv}`]"
v-for="item in data"
:key="item.id"
:data-lv="item.lv"
:data-id="item.id"
v-contextmenu="contextmenus"
>
<Input
class="editable-text"
:value="item.content"
v-if="activeItemId === item.id"
@blur="$event => handleBlur($event, item)"
@enter="$event => handleEnter($event, item)"
@backspace="$event => handleBackspace($event, item)"
/>
<div class="text" @click="handleFocus(item.id)" v-else>{{ item.content }}</div> <div class="text" @click="handleFocus(item.id)" v-else>{{ item.content }}</div>
<div class="flag"></div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, nextTick, onMounted } from 'vue' import { ref, nextTick, onMounted, watch } from 'vue'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import type { ContextmenuItem } from '@/components/Contextmenu/types'
import Input from './Input.vue' import Input from './Input.vue'
import message from '@/utils/message'
interface OutlineItem { interface OutlineItem {
id: string id: string
content: string content: string
lv: number lv: number
title?: boolean title?: boolean
item?: boolean
} }
const props = defineProps<{ const props = defineProps<{
value: string value: string
}>() }>()
const emit = defineEmits<{
(event: 'update:value', payload: string): void
}>()
const data = ref<OutlineItem[]>([]) const data = ref<OutlineItem[]>([])
const activeItemId = ref('') const activeItemId = ref('')
const editableRef = ref<InstanceType<typeof Input>[]>()
watch(data, () => {
let markdown = ''
const prefixTitle = '#'
const prefixItem = '-'
for (const item of data.value) {
if (item.lv !== 1) markdown += '\n'
if (item.title) markdown += `${prefixTitle.repeat(item.lv)} ${item.content}`
else markdown += `${prefixItem} ${item.content}`
}
emit('update:value', markdown)
})
onMounted(() => { onMounted(() => {
const lines = props.value.split('\n') const lines = props.value.split('\n')
@ -53,7 +85,6 @@ onMounted(() => {
result.push({ result.push({
id: nanoid(), id: nanoid(),
content, content,
item: true,
lv: 4, lv: 4,
}) })
} }
@ -72,28 +103,166 @@ const handleFocus = (id: string) => {
activeItemId.value = id activeItemId.value = id
nextTick(() => { nextTick(() => {
editableRef.value && editableRef.value[0].focus() const editableRef = document.querySelector('.editable-text input') as HTMLInputElement
editableRef.focus()
}) })
} }
const handleBlur = (e: Event, item: OutlineItem) => {
activeItemId.value = ''
const value = (e.target as HTMLInputElement).value
data.value = data.value.map(_item => {
if (_item.id === item.id) return { ..._item, content: value }
return _item
})
}
const handleEnter = (e: Event, item: OutlineItem) => {
const value = (e.target as HTMLInputElement).value
if (!value) return
activeItemId.value = ''
if (!item.title) {
const index = data.value.findIndex(_item => _item.id === item.id)
const newItemId = nanoid()
data.value.splice(index + 1, 0, { id: newItemId, content: '', lv: 4 })
nextTick(() => {
handleFocus(newItemId)
})
}
}
const handleBackspace = (e: Event, item: OutlineItem) => {
if (!item.title) {
const value = (e.target as HTMLInputElement).value
if (!value) deleteItem(item.id)
}
}
const addItem = (itemId: string, pos: 'next' | 'prev', content: string) => {
const index = data.value.findIndex(_item => _item.id === itemId)
const item = data.value[index]
if (!item) return
const id = nanoid()
let lv = 4
let i = 0
let title = false
if (pos === 'prev') i = index
else i = index + 1
if (item.lv === 1) lv = 2
else if (item.lv === 2) {
if (pos === 'prev') lv = 2
else lv = 3
}
else if (item.lv === 3) {
if (pos === 'prev') lv = 3
else lv = 4
}
else lv = 4
if (lv < 4) title = true
data.value.splice(i, 0, { id, content, lv, title })
}
const deleteItem = (itemId: string, isTitle?: boolean) => {
if (isTitle) {
const index = data.value.findIndex(item => item.id === itemId)
const item = data.value[index]
const nextItem = data.value[index + 1]
if (nextItem && nextItem.lv > item.lv) {
message.error('请先将子级大纲全部删除')
return
}
}
data.value = data.value.filter(item => item.id !== itemId)
}
const contextmenus = (el: HTMLElement): ContextmenuItem[] => {
const lv = +el.dataset.lv!
const id = el.dataset.id!
if (lv === 1) {
return [
{
text: '添加子级大纲(章)',
handler: () => addItem(id, 'next', '新的一章'),
},
]
}
else if (lv === 2) {
return [
{
text: '上方添加同级大纲(章)',
handler: () => addItem(id, 'prev', '新的一章'),
},
{
text: '添加子级大纲(节)',
handler: () => addItem(id, 'next', '新的一节'),
},
{
text: '删除此章',
handler: () => deleteItem(id, true),
},
]
}
else if (lv === 3) {
return [
{
text: '上方添加同级大纲(节)',
handler: () => addItem(id, 'prev', '新的一节'),
},
{
text: '添加子级大纲(项)',
handler: () => addItem(id, 'next', '新的一项'),
},
{
text: '删除此节',
handler: () => deleteItem(id, true),
},
]
}
return [
{
text: '上方添加同级大纲(项)',
handler: () => addItem(id, 'prev', '新的一项'),
},
{
text: '下方添加同级大纲(项)',
handler: () => addItem(id, 'next', '新的一项'),
},
{
text: '删除此项',
handler: () => deleteItem(id),
},
]
}
</script> </script>
<style lang="scss"> <style lang="scss">
.outline-editor { .outline-editor {
height: 600px; padding: 0 10px;
overflow: auto; padding-left: 40px;
position: relative;
.item { .item {
height: 32px; height: 32px;
position: relative;
& + .item { &.contextmenu-active {
margin-top: 2px; color: $themeColor;
} }
&.title { &.title {
font-weight: 700; font-weight: 700;
} }
&.lv-1 { &.lv-1 {
font-size: 20px; font-size: 22px;
} }
&.lv-2 { &.lv-2 {
font-size: 17px; font-size: 17px;
@ -103,13 +272,62 @@ const handleFocus = (id: string) => {
} }
&.lv-4 { &.lv-4 {
font-size: 13px; font-size: 13px;
padding-left: 20px;
} }
} }
.text { .text {
height: 100%; height: 100%;
color: #41464b; padding: 0 11px;
padding: 0 10px;
line-height: 32px; line-height: 32px;
@include ellipsis-oneline();
}
.flag {
width: 32px;
height: 32px;
position: absolute;
top: 50%;
left: -40px;
margin-top: -16px;
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
&::before {
content: '';
width: 1px;
height: 100%;
position: absolute;
left: 50%;
background-color: rgba($color: $themeColor, $alpha: .1);
}
&::after {
content: '';
width: 32px;
height: 22px;
border-radius: 2px;
background-color: #fff;
border: 1px solid $themeColor;
color: $themeColor;
position: relative;
display: flex;
justify-content: center;
align-items: center;
font-size: 12px;
font-weight: 400;
}
}
.item.lv-1 .flag::after {
content: '主题';
}
.item.lv-2 .flag::after {
content: '章';
}
.item.lv-3 .flag::after {
content: '节';
}
.item.lv-4 .flag::after {
opacity: 0;
} }
} }
</style> </style>

View File

@ -3,7 +3,7 @@
<div class="header"> <div class="header">
<span class="title">AIPPT</span> <span class="title">AIPPT</span>
<span class="subtite" v-if="step === 'template'">从下方挑选合适的模板开始生成PPT</span> <span class="subtite" v-if="step === 'template'">从下方挑选合适的模板开始生成PPT</span>
<span class="subtite" v-else-if="step === 'outline'">检查确认下方内容大纲开始挑选模板</span> <span class="subtite" v-else-if="step === 'outline'">确认下方内容大纲点击编辑内容右键添加/删除大纲项开始挑选模板</span>
<span class="subtite" v-else>在下方输入您的PPT主题并适当补充信息如行业岗位学科用途等</span> <span class="subtite" v-else>在下方输入您的PPT主题并适当补充信息如行业岗位学科用途等</span>
</div> </div>
@ -37,7 +37,10 @@
</div> </div>
</template> </template>
<div class="preview" v-if="step === 'outline'"> <div class="preview" v-if="step === 'outline'">
<pre ref="outlineRef">{{ outline }}</pre> <pre ref="outlineRef" v-if="outlineCreating">{{ outline }}</pre>
<div class="outline-view" v-else>
<OutlineEditor v-model:value="outline" />
</div>
<div class="btns" v-if="!outlineCreating"> <div class="btns" v-if="!outlineCreating">
<Button class="btn" type="primary" @click="step = 'template'">挑选模板</Button> <Button class="btn" type="primary" @click="step = 'template'">挑选模板</Button>
<Button class="btn" @click="outline = ''; step = 'setup'">返回重新生成</Button> <Button class="btn" @click="outline = ''; step = 'setup'">返回重新生成</Button>
@ -77,6 +80,7 @@ import Input from '@/components/Input.vue'
import Button from '@/components/Button.vue' import Button from '@/components/Button.vue'
import Select from '@/components/Select.vue' import Select from '@/components/Select.vue'
import FullscreenSpin from '@/components/FullscreenSpin.vue' import FullscreenSpin from '@/components/FullscreenSpin.vue'
import OutlineEditor from '@/components/OutlineEditor.vue'
const mainStore = useMainStore() const mainStore = useMainStore()
const { templates } = storeToRefs(useSlidesStore()) const { templates } = storeToRefs(useSlidesStore())
@ -190,6 +194,13 @@ const createPPT = async () => {
background-color: #f1f1f1; background-color: #f1f1f1;
overflow: auto; overflow: auto;
} }
.outline-view {
max-height: 450px;
padding: 10px;
margin-bottom: 15px;
background-color: #f1f1f1;
overflow: auto;
}
.btns { .btns {
display: flex; display: flex;
justify-content: center; justify-content: center;