在线PPT编辑器技术解析

在线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编辑器的技术要点:

  1. 数据驱动架构:所有元素都是纯数据对象,编辑即修改数据
  2. 虚拟坐标系:基准尺寸1000×562.5,通过CSS transform缩放
  3. 组件化设计:9种元素类型独立组件,编辑/演示分离
  4. 状态管理:Pinia管理Slides、Main、Snapshot等多个Store
  5. 历史记录:IndexedDB持久化,支持撤销/重做
  6. AI集成:大模型辅助PPT生成和内容优化

下一篇将介绍PPT后端服务的设计与实现。

阅读更多

Skills系统:可扩展AI能力设计

Skills系统:可扩展AI能力设计

概述 Skills系统是AI-Native架构中的重要组件,它允许通过声明式配置扩展AI的能力。本文将介绍Skills系统的设计与实现,让大模型能够像人类专家一样具备特定领域的能力。 什么是Skills系统 概念 Skills(技能)是一种声明式的AI能力扩展机制,类似于人类的"专业技能": 通用AI助手 专业AI助手(带Skills) ┌──────────────────────┐ ┌──────────────────────────────┐ │ │ │ │ │ 用户:请帮我写代码 │ │ 用户:请帮我审查这段代码 │ │ │ │ │ │ AI:我是一个AI助手 │ │ AI:[激活

By 菱角
插件化架构设计模式

插件化架构设计模式

概述 插件化架构是一种将核心功能与扩展功能分离的设计模式,允许系统在运行时动态加载和卸载功能模块。本文将介绍如何在微服务平台中设计和实现插件化架构。 为什么需要插件化 插件化优势 1. 模块化:功能独立,边界清晰 2. 可扩展:按需加载,动态增删 3. 隔离性:插件间互不干扰 4. 可维护:独立开发、测试、部署 5. 可定制:用户按需选择功能 核心设计 架构概览 核心组件实现 1. 插件接口定义 // core/plugin.interface.ts // 插件接口 export interface IPlugin { // 插件名称 readonly name: string // 插件版本 readonly version: string // 插件配置 getConfig(): PluginConfig // 插件清单

By 菱角
gRPC服务通信设计与实践

gRPC服务通信设计与实践

概述 在微服务架构中,服务间通信是关键环节。相比REST API,gRPC提供了更高的性能和更强的类型安全。本文将介绍如何在微服务平台中设计和实现gRPC服务通信。 为什么选择gRPC gRPC vs REST对比 特性 gRPC REST 协议 HTTP/2 HTTP/1.1 序列化 Protocol Buffers (二进制) JSON (文本) 性能 高(二进制+压缩) 中(文本开销) 类型安全 强(代码生成) 弱(运行时检查) 流式通信 原生支持(双向流) 需额外实现(SSE/WebSocket) 代码生成 自动生成 手动编写 浏览器支持 需gRPC-Web 原生支持 调试难度

By 菱角
多语言微服务架构:Node.js与Python协作

多语言微服务架构:Node.js与Python协作

概述 在微服务架构中,根据场景选择最适合的编程语言是最佳实践。本文将介绍如何在微服务平台中实现Node.js与Python的协作,发挥各自技术优势。 技术选型策略 为什么混合使用 服务划分 Node.js服务(7个) 服务 功能 选择Node.js的原因 llm.api 大模型服务 高并发SSE流式响应 ucenter.api 用户中心 RESTful API标准实践 doc.api 文件服务 流式上传下载处理 resource.api 资源管理 gRPC高性能通信 rag.api 知识库服务 MongoDB集成便利 statistic.api 统计分析 事件驱动架构 pptonline.api PPT服务 与前端技术栈统一 Python服务(1个) 服务 功能 选择Python的原因

By 菱角