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

View File

@ -1,32 +1,64 @@
<template>
<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">
<Input ref="editableRef" class="editable-text" :value="item.content" v-if="activeItemId === item.id" @blur="activeItemId = ''" />
<div class="item"
: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="flag"></div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, nextTick, onMounted } from 'vue'
import { ref, nextTick, onMounted, watch } from 'vue'
import { nanoid } from 'nanoid'
import type { ContextmenuItem } from '@/components/Contextmenu/types'
import Input from './Input.vue'
import message from '@/utils/message'
interface OutlineItem {
id: string
content: string
lv: number
title?: boolean
item?: boolean
}
const props = defineProps<{
value: string
}>()
const emit = defineEmits<{
(event: 'update:value', payload: string): void
}>()
const data = ref<OutlineItem[]>([])
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(() => {
const lines = props.value.split('\n')
@ -53,7 +85,6 @@ onMounted(() => {
result.push({
id: nanoid(),
content,
item: true,
lv: 4,
})
}
@ -72,28 +103,166 @@ const handleFocus = (id: string) => {
activeItemId.value = id
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>
<style lang="scss">
.outline-editor {
height: 600px;
overflow: auto;
padding: 0 10px;
padding-left: 40px;
position: relative;
.item {
height: 32px;
position: relative;
& + .item {
margin-top: 2px;
&.contextmenu-active {
color: $themeColor;
}
&.title {
font-weight: 700;
}
&.lv-1 {
font-size: 20px;
font-size: 22px;
}
&.lv-2 {
font-size: 17px;
@ -103,13 +272,62 @@ const handleFocus = (id: string) => {
}
&.lv-4 {
font-size: 13px;
padding-left: 20px;
}
}
.text {
height: 100%;
color: #41464b;
padding: 0 10px;
padding: 0 11px;
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>

View File

@ -3,7 +3,7 @@
<div class="header">
<span class="title">AIPPT</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>
</div>
@ -37,7 +37,10 @@
</div>
</template>
<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">
<Button class="btn" type="primary" @click="step = 'template'">挑选模板</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 Select from '@/components/Select.vue'
import FullscreenSpin from '@/components/FullscreenSpin.vue'
import OutlineEditor from '@/components/OutlineEditor.vue'
const mainStore = useMainStore()
const { templates } = storeToRefs(useSlidesStore())
@ -190,6 +194,13 @@ const createPPT = async () => {
background-color: #f1f1f1;
overflow: auto;
}
.outline-view {
max-height: 450px;
padding: 10px;
margin-bottom: 15px;
background-color: #f1f1f1;
overflow: auto;
}
.btns {
display: flex;
justify-content: center;