mirror of
https://github.com/pipipi-pikachu/PPTist.git
synced 2025-04-15 02:20:00 +08:00
feat: 添加AIPPT大纲编辑面板
This commit is contained in:
parent
1f5ca42a6d
commit
3c2830b12b
@ -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)
|
||||||
|
@ -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>
|
@ -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;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user