在线PPT编辑器技术解析
概述
PPTist是一个基于Web的在线演示文稿编辑与演示应用,还原了大部分PowerPoint的常用功能。本文将深入解析其前端技术架构,包括编辑器设计、元素系统、状态管理等核心模块。
项目架构
技术栈
| 技术 | 版本 | 用途 |
|---|---|---|
| Vue.js | 3.5.17 | 前端框架(组合式API) |
| TypeScript | 5.3.0 | 类型系统 |
| Vite | 5.3.5 | 构建工具 |
| Pinia | 3.0.2 | 状态管理 |
| Vue Router | 4.6.3 | 路由管理 |
| ProseMirror | - | 富文本编辑器 |
| ECharts | 6.0.0 | 图表库 |
| pptxgenjs | 3.12.0 | PPTX文件生成 |
目录结构
src/
├── assets/ # 静态资源
│ ├── fonts/ # 在线字体
│ ├── icons/ # 图标资源
│ └── styles/ # 全局样式
├── components/ # 通用UI组件
├── configs/ # 配置文件
│ ├── animation.ts # 动画配置
│ ├── chart.ts # 图表配置
│ ├── shapes.ts # 形状定义
│ └── hotkey.ts # 快捷键配置
├── hooks/ # 组合式函数(34个)
│ ├── useGlobalHotkey.ts # 全局快捷键
│ ├── useCreateElement.ts # 元素创建
│ ├── useExport.ts # 导出功能
│ └── ...
├── services/ # API服务
│ ├── axios.ts # Axios配置
│ ├── pptInfo.ts # PPT信息接口
│ └── llm.ts # AI接口
├── store/ # Pinia状态管理
│ ├── main.ts # 主状态
│ ├── slides.ts # 幻灯片状态
│ ├── snapshot.ts # 历史快照
│ └── ...
├── types/ # TypeScript类型定义
│ └── slides.ts # 幻灯片类型(786行)
├── utils/ # 工具函数
│ ├── prosemirror/ # 富文本编辑器
│ └── database.ts # IndexedDB操作
└── views/ # 视图组件
├── Editor/ # 编辑器模块
├── Screen/ # 演示模块
└── components/ # 业务共享组件
编辑器架构
三栏布局设计
┌─────────────────────────────────────────────────────────────────────────────┐
│ EditorHeader (40px) │
│ 顶部工具栏(文件/操作/预览) │
├──────────┬────────────────────────────────┬─────────────────┤
│ │ │ │
│ Thumbn- │ CanvasTool (40px) │ Toolbar │
│ ails │ 画布工具栏 │ (260px) │
│ (160px) ├────────────────────────────────┤ 右侧属性面板 │
│ 左侧 │ │ │
│ 缩略图 │ Canvas (动态高度) │ - 页面设置 │
│ 导航 │ 画布编辑区 │ - 元素属性 │
│ │ │ - 动画设置 │
│ │ ┌──────────────────────────┐ │ - 样式配置 │
│ │ │ Viewport (1000×562.5) │ │ │
│ │ │ 可视区域/画布 │ │ │
│ │ │ ┌────────────────────┐ │ │ │
│ │ │ │ EditableElement │ │ │ │
│ │ │ │ 可编辑元素层 │ │ │ │
│ │ │ └────────────────────┘ │ │ │
│ │ │ ┌────────────────────┐ │ │ │
│ │ │ │ Operate │ │ │ │
│ │ │ │ 操作层(拖拽/缩放) │ │ │ │
│ │ │ └────────────────────┘ │ │ │
│ │ └──────────────────────────┘ │ │
│ │ │ │
│ ├────────────────────────────────┤ │
│ │ Remark (40px+) │ │
│ │ 演讲者备注 │ │
│ │ │ │
└──────────┴────────────────────────────────┴─────────────────┘
Canvas核心组件
<!-- views/Editor/Canvas/index.vue -->
<template>
<div class="canvas" ref="canvasRef">
<!-- 画布工具栏 -->
<CanvasTool
:scale="canvasScale"
@scale-change="handleScaleChange"
/>
<!-- 画布容器 -->
<div
class="canvas-content"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
>
<!-- 视口 -->
<Viewport
:width="viewportWidth"
:height="viewportHeight"
:scale="canvasScale"
>
<!-- 可编辑元素 -->
<EditableElement
v-for="element in currentSlide.elements"
:key="element.id"
:element="element"
:isSelected="isSelected(element.id)"
@select="handleSelect"
@update="handleElementUpdate"
/>
<!-- 操作层(拖拽手柄、旋转) -->
<Operate
v-if="selectedElements.length > 0"
:elements="selectedElements"
@drag="handleDrag"
@resize="handleResize"
@rotate="handleRotate"
/>
</Viewport>
</div>
<!-- 演讲者备注 -->
<Remark
:content="currentSlide.remark"
@update="handleRemarkUpdate"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useSlidesStore } from '@/store/slides'
import { useMainStore } from '@/store/main'
const slidesStore = useSlidesStore()
const mainStore = useMainStore()
const { currentSlide } = storeToRefs(slidesStore)
const { selectedElementIds, canvasScale } = storeToRefs(mainStore)
const canvasRef = ref<HTMLElement>()
// 视口尺寸(基准尺寸 1000x562.5)
const viewportWidth = 1000
const viewportHeight = 562.5
// 选中的元素
const selectedElements = computed(() => {
return currentSlide.value.elements.filter(
el => selectedElementIds.value.includes(el.id)
)
})
// 处理元素拖拽
const handleDrag = (delta: { x: number; y: number }) => {
selectedElements.value.forEach(element => {
slidesStore.updateElement({
id: element.id,
props: {
left: element.left + delta.x / canvasScale.value,
top: element.top + delta.y / canvasScale.value,
},
})
})
}
// 处理元素缩放
const handleResize = (direction: string, delta: { x: number; y: number }) => {
const element = selectedElements.value[0]
if (!element) return
const newProps = calculateResize(element, direction, delta, canvasScale.value)
slidesStore.updateElement({ id: element.id, props: newProps })
}
</script>
元素系统
元素类型定义
// types/slides.ts
// 元素类型枚举
export const enum ElementTypes {
TEXT = 'text', // 文本
IMAGE = 'image', // 图片
SHAPE = 'shape', // 形状
LINE = 'line', // 线条
CHART = 'chart', // 图表
TABLE = 'table', // 表格
LATEX = 'latex', // 公式
VIDEO = 'video', // 视频
AUDIO = 'audio', // 音频
}
// 元素基类
export interface PPTBaseElement {
id: string // 元素ID
left: number // X坐标
top: number // Y坐标
width: number // 宽度
height: number // 高度
rotate?: number // 旋转角度(度)
lock?: boolean // 是否锁定
groupId?: string // 组合ID
link?: PPTElementLink // 超链接
name?: string // 元素名称
}
// 文本元素
export interface PPTTextElement extends PPTBaseElement {
type: ElementTypes.TEXT
content: string // HTML内容
defaultFontName: string // 默认字体
defaultColor: string // 默认颜色
lineHeight: number // 行高
wordSpace: number // 字间距
paragraphSpace: number // 段间距
vertical?: TextVertical // 竖排文字
}
// 图片元素
export interface PPTImageElement extends PPTBaseElement {
type: ElementTypes.IMAGE
src: string // 图片URL
clip?: ImageClip // 裁剪信息
outline?: ImageOutline // 轮廓
filters?: ImageFilters // 滤镜
flipH?: boolean // 水平翻转
flipV?: boolean // 垂直翻转
}
// 形状元素
export interface PPTShapeElement extends PPTBaseElement {
type: ElementTypes.SHAPE
shape: ShapeType // 形状类型
fill: string | ShapeGradient | ShapePattern
outline?: ShapeOutline // 边框
text?: ShapeText // 形状内文字
}
可编辑元素组件
<!-- views/Editor/Canvas/EditableElement.vue -->
<template>
<div
class="editable-element"
:style="elementPositionStyle"
:class="{ selected: isSelected }"
@mousedown.stop="handleSelect"
>
<!-- 根据元素类型渲染对应组件 -->
<component
:is="elementComponent"
:element="element"
:style="elementStyle"
/>
</div>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent } from 'vue'
import type { PPTElement } from '@/types/slides'
const props = defineProps<{
element: PPTElement
isSelected: boolean
}>()
const emit = defineEmits<{
select: [id: string]
}>()
// 动态加载元素组件
const elementComponent = computed(() => {
const componentMap: Record<string, any> = {
text: defineAsyncComponent(() => import('./TextElement/index.vue')),
image: defineAsyncComponent(() => import('./ImageElement/index.vue')),
shape: defineAsyncComponent(() => import('./ShapeElement/index.vue')),
chart: defineAsyncComponent(() => import('./ChartElement/index.vue')),
table: defineAsyncComponent(() => import('./TableElement/index.vue')),
}
return componentMap[props.element.type] || 'div'
})
// 元素位置样式(基于基准尺寸)
const elementPositionStyle = computed(() => {
const { left, top, width, height, rotate = 0 } = props.element
return {
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
transform: `rotate(${rotate}deg)`,
transformOrigin: 'center center',
}
})
// 元素内部样式
const elementStyle = computed(() => ({
width: '100%',
height: '100%',
}))
const handleSelect = () => {
emit('select', props.element.id)
}
</script>
文本元素实现
<!-- views/components/element/TextElement/index.vue -->
<template>
<div class="text-element" :style="textStyle">
<ProseMirrorEditor
:content="element.content"
:editable="true"
@update="handleContentUpdate"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { PPTTextElement } from '@/types/slides'
const props = defineProps<{
element: PPTTextElement
}>()
const textStyle = computed(() => ({
fontFamily: props.element.defaultFontName,
color: props.element.defaultColor,
lineHeight: props.element.lineHeight,
letterSpacing: `${props.element.wordSpace}px`,
}))
const handleContentUpdate = (content: string) => {
// 更新元素内容
console.log('Content updated:', content)
}
</script>
状态管理
Slides Store
// store/slides.ts
import { defineStore } from 'pinia'
import type { Slide, PPTTheme, ViewportRatio } from '@/types/slides'
export const useSlidesStore = defineStore('slides', {
state: () => ({
slides: [] as Slide[], // 幻灯片列表
theme: {} as PPTTheme, // 主题配置
viewportRatio: 0.5625 as ViewportRatio, // 视口比例(16:9)
currentSlideIndex: 0, // 当前幻灯片索引
}),
getters: {
// 当前幻灯片
currentSlide: (state) => state.slides[state.currentSlideIndex] || null,
// 幻灯片数量
slidesCount: (state) => state.slides.length,
},
actions: {
// 设置幻灯片数据
setSlides(slides: Slide[]) {
this.slides = slides
},
// 添加幻灯片
addSlide(slide: Slide, index?: number) {
if (index !== undefined) {
this.slides.splice(index, 0, slide)
this.currentSlideIndex = index
} else {
this.slides.push(slide)
this.currentSlideIndex = this.slides.length - 1
}
},
// 删除幻灯片
deleteSlide(index: number) {
this.slides.splice(index, 1)
if (this.currentSlideIndex >= index && this.currentSlideIndex > 0) {
this.currentSlideIndex--
}
},
// 更新幻灯片
updateSlide(index: number, slide: Partial<Slide>) {
this.slides[index] = { ...this.slides[index], ...slide }
},
// 更新元素
updateElement({ id, props }: { id: string; props: Partial<PPTElement> }) {
const slide = this.currentSlide
if (!slide) return
const elementIndex = slide.elements.findIndex(el => el.id === id)
if (elementIndex >= 0) {
slide.elements[elementIndex] = {
...slide.elements[elementIndex],
...props,
}
}
},
// 添加元素
addElement(element: PPTElement) {
const slide = this.currentSlide
if (!slide) return
slide.elements.push(element)
},
// 删除元素
deleteElement(id: string) {
const slide = this.currentSlide
if (!slide) return
const index = slide.elements.findIndex(el => el.id === id)
if (index >= 0) {
slide.elements.splice(index, 1)
}
},
// 切换幻灯片
setCurrentSlideIndex(index: number) {
if (index >= 0 && index < this.slides.length) {
this.currentSlideIndex = index
}
},
},
})
Main Store
// store/main.ts
import { defineStore } from 'pinia'
export const useMainStore = defineStore('main', {
state: () => ({
// 选中状态
selectedElementIds: [] as string[], // 选中的元素ID列表
activeElementId: '', // 当前激活的元素ID
// 画布状态
canvasScale: 1, // 画布缩放比例
canvasShowGrid: false, // 是否显示网格
// 编辑状态
isEditing: false, // 是否正在编辑文本
isDragging: false, // 是否正在拖拽
isResizing: false, // 是否正在缩放
// 剪贴板
clipboard: null as Slide | PPTElement | null,
// 历史记录指针(用于撤销/重做)
snapshotCursor: -1,
}),
getters: {
// 是否有选中的元素
hasSelectedElement: (state) => state.selectedElementIds.length > 0,
// 选中的元素数量
selectedElementCount: (state) => state.selectedElementIds.length,
},
actions: {
// 设置选中元素
setSelectedElementIds(ids: string[]) {
this.selectedElementIds = ids
this.activeElementId = ids[ids.length - 1] || ''
},
// 添加选中元素
addSelectedElementId(id: string) {
if (!this.selectedElementIds.includes(id)) {
this.selectedElementIds.push(id)
}
},
// 设置画布缩放
setCanvasScale(scale: number) {
this.canvasScale = Math.max(0.5, Math.min(2, scale))
},
// 设置编辑状态
setEditing(isEditing: boolean) {
this.isEditing = isEditing
},
},
})
Snapshot Store(历史记录)
// store/snapshot.ts
import Dexie from 'dexie'
import type { Slide } from '@/types/slides'
// IndexedDB数据库
const db = new Dexie('PPTistDB')
db.version(1).stores({
snapshots: '++id, timestamp',
})
export const useSnapshotStore = defineStore('snapshot', {
state: () => ({
snapshots: [] as { id: number; slides: Slide[]; index: number }[],
maxSnapshots: 20, // 最大快照数
}),
actions: {
// 添加快照
async addSnapshot(slides: Slide[], currentIndex: number) {
const snapshot = {
slides: JSON.parse(JSON.stringify(slides)),
index: currentIndex,
timestamp: Date.now(),
}
// 保存到IndexedDB
const id = await db.table('snapshots').add(snapshot)
// 更新内存中的列表
this.snapshots.push({ id, ...snapshot })
// 限制快照数量
if (this.snapshots.length > this.maxSnapshots) {
const removed = this.snapshots.shift()
if (removed) {
await db.table('snapshots').delete(removed.id)
}
}
},
// 撤销
async undo(currentCursor: number): Promise<{ slides: Slide[]; index: number } | null> {
if (currentCursor <= 0) return null
const snapshot = this.snapshots[currentCursor - 1]
if (!snapshot) return null
return {
slides: JSON.parse(JSON.stringify(snapshot.slides)),
index: snapshot.index,
}
},
// 重做
async redo(currentCursor: number): Promise<{ slides: Slide[]; index: number } | null> {
if (currentCursor >= this.snapshots.length - 1) return null
const snapshot = this.snapshots[currentCursor + 1]
if (!snapshot) return null
return {
slides: JSON.parse(JSON.stringify(snapshot.slides)),
index: snapshot.index,
}
},
},
})
快捷键系统
// hooks/useGlobalHotkey.ts
import { onMounted, onUnmounted } from 'vue'
import { useMainStore, useSlidesStore } from '@/store'
export default () => {
const mainStore = useMainStore()
const slidesStore = useSlidesStore()
const keydownHandler = (e: KeyboardEvent) => {
// 如果在编辑状态,不处理快捷键
if (mainStore.isEditing) return
const { ctrlKey, shiftKey, key } = e
// Ctrl + C: 复制
if (ctrlKey && key === 'c') {
e.preventDefault()
copyElement()
}
// Ctrl + V: 粘贴
if (ctrlKey && key === 'v') {
e.preventDefault()
pasteElement()
}
// Ctrl + Z: 撤销
if (ctrlKey && !shiftKey && key === 'z') {
e.preventDefault()
undo()
}
// Ctrl + Shift + Z 或 Ctrl + Y: 重做
if ((ctrlKey && shiftKey && key === 'z') || (ctrlKey && key === 'y')) {
e.preventDefault()
redo()
}
// Delete: 删除
if (key === 'Delete' || key === 'Backspace') {
deleteElement()
}
// Ctrl + G: 组合
if (ctrlKey && key === 'g') {
e.preventDefault()
groupElements()
}
// Ctrl + Shift + G: 取消组合
if (ctrlKey && shiftKey && key === 'g') {
e.preventDefault()
ungroupElements()
}
// Ctrl + =: 放大
if (ctrlKey && (key === '=' || key === '+')) {
e.preventDefault()
mainStore.setCanvasScale(mainStore.canvasScale + 0.1)
}
// Ctrl + -: 缩小
if (ctrlKey && key === '-') {
e.preventDefault()
mainStore.setCanvasScale(mainStore.canvasScale - 0.1)
}
}
onMounted(() => {
document.addEventListener('keydown', keydownHandler)
})
onUnmounted(() => {
document.removeEventListener('keydown', keydownHandler)
})
}
导出功能
// hooks/useExport.ts
import pptxgen from 'pptxgenjs'
import { toPng } from 'html-to-image'
import type { Slide } from '@/types/slides'
export default () => {
// 导出为PPTX
const exportPPTX = async (slides: Slide[]) => {
const pres = new pptxgen()
slides.forEach(slide => {
const pptxSlide = pres.addSlide()
slide.elements.forEach(element => {
switch (element.type) {
case 'text':
pptxSlide.addText(element.content, {
x: element.left / 1000,
y: element.top / 562.5,
w: element.width / 1000,
h: element.height / 562.5,
fontSize: 18,
})
break
case 'image':
pptxSlide.addImage({
path: element.src,
x: element.left / 1000,
y: element.top / 562.5,
w: element.width / 1000,
h: element.height / 562.5,
})
break
}
})
})
await pres.writeFile({ fileName: 'presentation.pptx' })
}
// 导出为图片
const exportImage = async (slideElement: HTMLElement) => {
const dataUrl = await toPng(slideElement, {
quality: 1,
pixelRatio: 2,
})
const link = document.createElement('a')
link.download = 'slide.png'
link.href = dataUrl
link.click()
}
// 导出为JSON
const exportJSON = (slides: Slide[]) => {
const json = JSON.stringify(slides, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.download = 'presentation.json'
link.href = url
link.click()
URL.revokeObjectURL(url)
}
return {
exportPPTX,
exportImage,
exportJSON,
}
}
AI集成功能
// services/llm.ts
import axios from 'axios'
// AI生成PPT大纲
export const AIPPT_Outline = async (topic: string, pages: number) => {
const response = await axios.post('/api/llm/ppt/outline', {
topic,
pages,
})
return response.data
}
// AI生成完整PPT
export const AIPPT = async (outline: any, options: any) => {
const response = await axios.post('/api/llm/ppt/generate', {
outline,
options,
})
return response.data
}
// AI改写文本
export const AI_Writing = async (text: string, type: 'rewrite' | 'expand' | 'shorten') => {
const response = await axios.post('/api/llm/writing', {
text,
type,
})
return response.data
}
总结
在线PPT编辑器的技术要点:
- 数据驱动架构:所有元素都是纯数据对象,编辑即修改数据
- 虚拟坐标系:基准尺寸1000×562.5,通过CSS transform缩放
- 组件化设计:9种元素类型独立组件,编辑/演示分离
- 状态管理:Pinia管理Slides、Main、Snapshot等多个Store
- 历史记录:IndexedDB持久化,支持撤销/重做
- AI集成:大模型辅助PPT生成和内容优化
下一篇将介绍PPT后端服务的设计与实现。