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)"
|
||||
@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)
|
||||
|
@ -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;
|
||||
|
||||
& + .item {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
|
||||
&.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>
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user