Browse Source

ppt 添加云教练功能

黄琪勇 3 months ago
parent
commit
2bc887d16a
29 changed files with 1763 additions and 1320 deletions
  1. 3 0
      .env.development
  2. 2 0
      src/config/index.ts
  3. 94 71
      src/hooks/useCreateElement.ts
  4. 444 429
      src/mocks/layout.ts
  5. 78 77
      src/mocks/slides.ts
  6. 10 10
      src/mocks/theme.ts
  7. 4 2
      src/plugins/icon.ts
  8. 260 235
      src/types/slides.ts
  9. 76 77
      src/views/Editor/Canvas/EditableElement.vue
  10. 24 26
      src/views/Editor/Canvas/Operate/index.vue
  11. 105 125
      src/views/Editor/Canvas/index.vue
  12. 167 77
      src/views/Editor/CanvasTool/index.vue
  13. 21 21
      src/views/Editor/Toolbar/ElementAnimationPanel.vue
  14. 7 0
      src/views/Editor/Toolbar/ElementStylePanel/CloudCoachStylePanel.vue
  15. 20 18
      src/views/Editor/Toolbar/ElementStylePanel/index.vue
  16. 35 34
      src/views/Editor/Toolbar/index.vue
  17. 19 27
      src/views/Editor/index.vue
  18. 24 26
      src/views/Screen/ScreenElement.vue
  19. 17 19
      src/views/components/ThumbnailSlide/ThumbnailElement.vue
  20. 24 25
      src/views/components/ThumbnailSlide/index.vue
  21. 19 21
      src/views/components/element/AudioElement/ScreenAudioElement.vue
  22. 47 0
      src/views/components/element/cloudCoachElement/BaseCloudCoachElement.vue
  23. 51 0
      src/views/components/element/cloudCoachElement/ScreenCloudCoachElement.vue
  24. 98 0
      src/views/components/element/cloudCoachElement/cloudCoachElement.vue
  25. 22 0
      src/views/components/element/cloudCoachElement/cloudCoachList/cloudCoachList.vue
  26. 2 0
      src/views/components/element/cloudCoachElement/cloudCoachList/index.ts
  27. 86 0
      src/views/components/element/cloudCoachElement/cloudCoachPlayer/cloudCoachPlayer.vue
  28. 2 0
      src/views/components/element/cloudCoachElement/cloudCoachPlayer/index.ts
  29. 2 0
      src/views/components/element/cloudCoachElement/index.ts

+ 3 - 0
.env.development

@@ -1,2 +1,5 @@
 
 VITE_APP_URL = "/pptApi"
+
+## 云教练地址
+VITE_YJL_URL = "https://test.kt.colexiu.com/instrument"

+ 2 - 0
src/config/index.ts

@@ -1 +1,3 @@
 export const URL_API = import.meta.env.VITE_APP_URL as string
+
+export const YJL_URL_API = import.meta.env.VITE_YJL_URL as string

+ 94 - 71
src/hooks/useCreateElement.ts

@@ -1,12 +1,12 @@
-import { storeToRefs } from 'pinia'
-import { nanoid } from 'nanoid'
-import { useMainStore, useSlidesStore } from '@/store'
-import { getImageSize } from '@/utils/image'
-import type { PPTLineElement, PPTElement, TableCell, TableCellStyle, PPTShapeElement, ChartType } from '@/types/slides'
-import { type ShapePoolItem, SHAPE_PATH_FORMULAS } from '@/configs/shapes'
-import type { LinePoolItem } from '@/configs/lines'
-import { CHART_DEFAULT_DATA } from '@/configs/chart'
-import useHistorySnapshot from '@/hooks/useHistorySnapshot'
+import { storeToRefs } from "pinia"
+import { nanoid } from "nanoid"
+import { useMainStore, useSlidesStore } from "@/store"
+import { getImageSize } from "@/utils/image"
+import type { PPTLineElement, PPTElement, TableCell, TableCellStyle, PPTShapeElement, ChartType } from "@/types/slides"
+import { type ShapePoolItem, SHAPE_PATH_FORMULAS } from "@/configs/shapes"
+import type { LinePoolItem } from "@/configs/lines"
+import { CHART_DEFAULT_DATA } from "@/configs/chart"
+import useHistorySnapshot from "@/hooks/useHistorySnapshot"
 
 interface CommonElementPosition {
   top: number
@@ -58,18 +58,17 @@ export default () => {
   const createImageElement = (src: string) => {
     getImageSize(src).then(({ width, height }) => {
       const scale = height / width
-  
+
       if (scale < viewportRatio.value && width > viewportSize.value) {
         width = viewportSize.value
         height = width * scale
-      }
-      else if (height > viewportSize.value * viewportRatio.value) {
+      } else if (height > viewportSize.value * viewportRatio.value) {
         height = viewportSize.value * viewportRatio.value
         width = height / scale
       }
 
       createElement({
-        type: 'image',
+        type: "image",
         id: nanoid(10),
         src,
         width,
@@ -77,18 +76,18 @@ export default () => {
         left: (viewportSize.value - width) / 2,
         top: (viewportSize.value * viewportRatio.value - height) / 2,
         fixedRatio: true,
-        rotate: 0,
+        rotate: 0
       })
     })
   }
-  
+
   /**
    * 创建图表元素
    * @param chartType 图表类型
    */
   const createChartElement = (type: ChartType) => {
     createElement({
-      type: 'chart',
+      type: "chart",
       id: nanoid(10),
       chartType: type,
       left: 300,
@@ -98,10 +97,10 @@ export default () => {
       rotate: 0,
       themeColors: [theme.value.themeColor],
       textColor: theme.value.fontColor,
-      data: CHART_DEFAULT_DATA[type],
+      data: CHART_DEFAULT_DATA[type]
     })
   }
-  
+
   /**
    * 创建表格元素
    * @param row 行数
@@ -110,13 +109,13 @@ export default () => {
   const createTableElement = (row: number, col: number) => {
     const style: TableCellStyle = {
       fontname: theme.value.fontName,
-      color: theme.value.fontColor,
+      color: theme.value.fontColor
     }
     const data: TableCell[][] = []
     for (let i = 0; i < row; i++) {
       const rowCells: TableCell[] = []
       for (let j = 0; j < col; j++) {
-        rowCells.push({ id: nanoid(10), colspan: 1, rowspan: 1, text: '', style })
+        rowCells.push({ id: nanoid(10), colspan: 1, rowspan: 1, text: "", style })
       }
       data.push(rowCells)
     }
@@ -130,7 +129,7 @@ export default () => {
     const height = row * DEFAULT_CELL_HEIGHT
 
     createElement({
-      type: 'table',
+      type: "table",
       id: nanoid(10),
       width,
       height,
@@ -141,20 +140,20 @@ export default () => {
       top: (viewportSize.value * viewportRatio.value - height) / 2,
       outline: {
         width: 2,
-        style: 'solid',
-        color: '#eeece1',
+        style: "solid",
+        color: "#eeece1"
       },
       theme: {
         color: theme.value.themeColor,
         rowHeader: true,
         rowFooter: false,
         colHeader: false,
-        colFooter: false,
+        colFooter: false
       },
-      cellMinHeight: 36,
+      cellMinHeight: 36
     })
   }
-  
+
   /**
    * 创建文本元素
    * @param position 位置大小信息
@@ -162,30 +161,33 @@ export default () => {
    */
   const createTextElement = (position: CommonElementPosition, data?: CreateTextData) => {
     const { left, top, width, height } = position
-    const content = data?.content || ''
+    const content = data?.content || ""
     const vertical = data?.vertical || false
 
     const id = nanoid(10)
-    createElement({
-      type: 'text',
-      id,
-      left, 
-      top, 
-      width, 
-      height,
-      content,
-      rotate: 0,
-      defaultFontName: theme.value.fontName,
-      defaultColor: theme.value.fontColor,
-      vertical,
-    }, () => {
-      setTimeout(() => {
-        const editorRef: HTMLElement | null = document.querySelector(`#editable-element-${id} .ProseMirror`)
-        if (editorRef) editorRef.focus()
-      }, 0)
-    })
+    createElement(
+      {
+        type: "text",
+        id,
+        left,
+        top,
+        width,
+        height,
+        content,
+        rotate: 0,
+        defaultFontName: theme.value.fontName,
+        defaultColor: theme.value.fontColor,
+        vertical
+      },
+      () => {
+        setTimeout(() => {
+          const editorRef: HTMLElement | null = document.querySelector(`#editable-element-${id} .ProseMirror`)
+          if (editorRef) editorRef.focus()
+        }, 0)
+      }
+    )
   }
-  
+
   /**
    * 创建形状元素
    * @param position 位置大小信息
@@ -194,18 +196,18 @@ export default () => {
   const createShapeElement = (position: CommonElementPosition, data: ShapePoolItem, supplement: Partial<PPTShapeElement> = {}) => {
     const { left, top, width, height } = position
     const newElement: PPTShapeElement = {
-      type: 'shape',
+      type: "shape",
       id: nanoid(10),
-      left, 
-      top, 
-      width, 
+      left,
+      top,
+      width,
       height,
       viewBox: data.viewBox,
       path: data.path,
       fill: theme.value.themeColor,
       fixedRatio: false,
       rotate: 0,
-      ...supplement,
+      ...supplement
     }
     if (data.withborder) newElement.outline = theme.value.outline
     if (data.special) newElement.special = true
@@ -214,15 +216,14 @@ export default () => {
       newElement.viewBox = [width, height]
 
       const pathFormula = SHAPE_PATH_FORMULAS[data.pathFormula]
-      if ('editable' in pathFormula && pathFormula.editable) {
+      if ("editable" in pathFormula && pathFormula.editable) {
         newElement.path = pathFormula.formula(width, height, pathFormula.defaultValue!)
         newElement.keypoints = pathFormula.defaultValue
-      }
-      else newElement.path = pathFormula.formula(width, height)
+      } else newElement.path = pathFormula.formula(width, height)
     }
     createElement(newElement)
   }
-  
+
   /**
    * 创建线条元素
    * @param position 位置大小信息
@@ -232,31 +233,35 @@ export default () => {
     const { left, top, start, end } = position
 
     const newElement: PPTLineElement = {
-      type: 'line',
+      type: "line",
       id: nanoid(10),
-      left, 
-      top, 
+      left,
+      top,
       start,
       end,
       points: data.points,
       color: theme.value.themeColor,
       style: data.style,
-      width: 2,
+      width: 2
     }
     if (data.isBroken) newElement.broken = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
     if (data.isBroken2) newElement.broken2 = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
     if (data.isCurve) newElement.curve = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
-    if (data.isCubic) newElement.cubic = [[(start[0] + end[0]) / 2, (start[1] + end[1]) / 2], [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]]
+    if (data.isCubic)
+      newElement.cubic = [
+        [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2],
+        [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
+      ]
     createElement(newElement)
   }
-  
+
   /**
    * 创建LaTeX元素
    * @param svg SVG代码
    */
-  const createLatexElement = (data: { path: string; latex: string; w: number; h: number; }) => {
+  const createLatexElement = (data: { path: string; latex: string; w: number; h: number }) => {
     createElement({
-      type: 'latex',
+      type: "latex",
       id: nanoid(10),
       width: data.w,
       height: data.h,
@@ -268,17 +273,17 @@ export default () => {
       color: theme.value.fontColor,
       strokeWidth: 2,
       viewBox: [data.w, data.h],
-      fixedRatio: true,
+      fixedRatio: true
     })
   }
-  
+
   /**
    * 创建视频元素
    * @param src 视频地址
    */
   const createVideoElement = (src: string) => {
     createElement({
-      type: 'video',
+      type: "video",
       id: nanoid(10),
       width: 500,
       height: 300,
@@ -286,17 +291,17 @@ export default () => {
       left: (viewportSize.value - 500) / 2,
       top: (viewportSize.value * viewportRatio.value - 300) / 2,
       src,
-      autoplay: false,
+      autoplay: false
     })
   }
-  
+
   /**
    * 创建音频元素
    * @param src 音频地址
    */
   const createAudioElement = (src: string) => {
     createElement({
-      type: 'audio',
+      type: "audio",
       id: nanoid(10),
       width: 50,
       height: 50,
@@ -307,7 +312,24 @@ export default () => {
       autoplay: false,
       fixedRatio: true,
       color: theme.value.themeColor,
-      src,
+      src
+    })
+  }
+
+  /**
+   * 创建云教练元素
+   * @param url 云教练地址
+   */
+  const createCloudCoachElement = (url: string) => {
+    createElement({
+      type: "cloudCoach",
+      id: nanoid(10),
+      width: 500,
+      height: 300,
+      rotate: 0,
+      left: (viewportSize.value - 500) / 2,
+      top: (viewportSize.value * viewportRatio.value - 300) / 2,
+      url
     })
   }
 
@@ -321,5 +343,6 @@ export default () => {
     createLatexElement,
     createVideoElement,
     createAudioElement,
+    createCloudCoachElement
   }
-}
+}

+ 444 - 429
src/mocks/layout.ts

@@ -1,968 +1,983 @@
 /* eslint-disable max-lines */
 
-import type { Slide } from '@/types/slides'
+import type { Slide } from "@/types/slides"
 
 export const layouts: Slide[] = [
   {
-    id: 'template',
+    id: "template",
     elements: [
       {
-        type: 'shape',
-        id: '4cbRxp',
+        type: "shape",
+        id: "4cbRxp",
         left: 0,
         top: 200,
         width: 546,
         height: 362.5,
         viewBox: [200, 200],
-        path: 'M 0 0 L 0 200 L 200 200 Z',
-        fill: '{{themeColor}}',
+        path: "M 0 0 L 0 200 L 200 200 Z",
+        fill: "{{themeColor}}",
         fixedRatio: false,
         opacity: 0.7,
         rotate: 0
       },
       {
-        type: 'shape',
-        id: 'ookHrf',
+        type: "shape",
+        id: "ookHrf",
         left: 0,
         top: 0,
         width: 300,
         height: 320,
         viewBox: [200, 200],
-        path: 'M 0 0 L 0 200 L 200 200 Z',
-        fill: '{{themeColor}}',
+        path: "M 0 0 L 0 200 L 200 200 Z",
+        fill: "{{themeColor}}",
         fixedRatio: false,
         flipV: true,
         rotate: 0
       },
       {
-        type: 'text',
-        id: 'AkIh3E',
+        type: "text",
+        id: "AkIh3E",
         left: 355,
         top: 95.11111111111111,
         width: 585,
         height: 116,
         lineHeight: 1.2,
-        content: '<p style=\'\'><strong><span style=\'font-size: 80px\'>输入标题</span></strong></p>',
+        content: "<p style=''><strong><span style='font-size: 80px'>输入标题</span></strong></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
         wordSpace: 6
       },
       {
-        type: 'text',
-        id: '7stmVP',
+        type: "text",
+        id: "7stmVP",
         left: 355,
         top: 253.25,
         width: 585,
         height: 56,
-        content: '<p><span style=\'font-size:  24px\'>请在此处输入副标题</span></p>',
+        content: "<p><span style='font-size:  24px'>请在此处输入副标题</span></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}"
       },
       {
-        type: 'line',
-        id: 'FnpZs4',
+        type: "line",
+        id: "FnpZs4",
         left: 361,
         top: 238,
         start: [0, 0],
         end: [549, 0],
-        points: ['', ''],
-        color: '{{themeColor}}',
-        style: 'solid',
-        width: 2,
-      },
+        points: ["", ""],
+        color: "{{themeColor}}",
+        style: "solid",
+        width: 2
+      }
     ],
     background: {
-      type: 'solid',
-      color: '{{backgroundColor}}',
-    },
+      type: "solid",
+      color: "{{backgroundColor}}"
+    }
   },
   {
-    id: 'template',
+    id: "template",
     elements: [
       {
-        type: 'text',
-        id: 'ptNnUJ',
+        type: "text",
+        id: "ptNnUJ",
         left: 145,
         top: 148,
         width: 711,
         height: 77,
         lineHeight: 1.2,
-        content: '<p style=\'text-align: center;\'><strong><span style=\'font-size: 48px\'>在此处添加标题</span></strong></p>',
+        content: "<p style='text-align: center;'><strong><span style='font-size: 48px'>在此处添加标题</span></strong></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-      }, 
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}"
+      },
       {
-        type: 'text',
-        id: 'mRHvQN',
+        type: "text",
+        id: "mRHvQN",
         left: 207.50000000000003,
         top: 249.84259259259264,
         width: 585,
         height: 56,
-        content: '<p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处添加副标题</span></p>',
+        content: "<p style='text-align: center;'><span style='font-size: 24px'>在此处添加副标题</span></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-      }, 
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}"
+      },
       {
-        type: 'line',
-        id: '7CQDwc',
+        type: "line",
+        id: "7CQDwc",
         left: 323.09259259259267,
         top: 238.33333333333334,
         start: [0, 0],
         end: [354.8148148148148, 0],
-        points: ['', ''],
-        color: '{{themeColor}}',
-        style: 'solid',
+        points: ["", ""],
+        color: "{{themeColor}}",
+        style: "solid",
         width: 4
-      }, 
+      },
       {
-        type: 'shape',
-        id: '09wqWw',
+        type: "shape",
+        id: "09wqWw",
         left: -27.648148148148138,
         top: 432.73148148148147,
         width: 1056.2962962962963,
         height: 162.96296296296296,
         viewBox: [200, 200],
-        path: 'M 0 20 C 40 -40 60 60 100 20 C 140 -40 160 60 200 20 L 200 180 C 140 240 160 140 100 180 C 40 240 60 140 0 180 L 0 20 Z',
-        fill: '{{themeColor}}',
+        path: "M 0 20 C 40 -40 60 60 100 20 C 140 -40 160 60 200 20 L 200 180 C 140 240 160 140 100 180 C 40 240 60 140 0 180 L 0 20 Z",
+        fill: "{{themeColor}}",
         fixedRatio: false,
         rotate: 0
       }
     ],
     background: {
-      type: 'solid',
-      color: '{{backgroundColor}}',
-    },
+      type: "solid",
+      color: "{{backgroundColor}}"
+    }
   },
   {
-    id: 'template',
+    id: "template",
     elements: [
       {
-        type: 'shape',
-        id: 'vSheCJ',
+        type: "shape",
+        id: "vSheCJ",
         left: 183.5185185185185,
         top: 175.5092592592593,
         width: 605.1851851851851,
         height: 185.18518518518516,
         viewBox: [200, 200],
-        path: 'M 0 0 L 200 0 L 200 200 L 0 200 Z',
-        fill: '{{themeColor}}',
+        path: "M 0 0 L 200 0 L 200 200 L 0 200 Z",
+        fill: "{{themeColor}}",
         fixedRatio: false,
         rotate: 0
-      }, 
+      },
       {
-        type: 'shape',
-        id: 'Mpwv7x',
+        type: "shape",
+        id: "Mpwv7x",
         left: 211.29629629629628,
         top: 201.80555555555557,
         width: 605.1851851851851,
         height: 185.18518518518516,
         viewBox: [200, 200],
-        path: 'M 0 0 L 200 0 L 200 200 L 0 200 Z',
-        fill: '{{themeColor}}',
+        path: "M 0 0 L 200 0 L 200 200 L 0 200 Z",
+        fill: "{{themeColor}}",
         fixedRatio: false,
         rotate: 0,
         opacity: 0.7
-      }, 
+      },
       {
-        type: 'text',
-        id: 'WQOTAp',
+        type: "text",
+        id: "WQOTAp",
         left: 304.9074074074074,
         top: 198.10185185185182,
         width: 417.9629629629629,
         height: 140,
-        content: '<p style=\'text-align: center;\'><strong><span style=\'color: #ffffff;\'><span style=\'font-size: 80px\'>感谢观看</span></span></strong></p>',
+        content:
+          "<p style='text-align: center;'><strong><span style='color: #ffffff;'><span style='font-size: 80px'>感谢观看</span></span></strong></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
         wordSpace: 5
       }
     ],
     background: {
-      type: 'solid',
-      color: '{{backgroundColor}}',
-    },
+      type: "solid",
+      color: "{{backgroundColor}}"
+    }
   },
   {
-    id: 'MZVO1kkj',
+    id: "MZVO1kkj",
     elements: [
       {
-        type: 'shape',
-        id: 'cql0h8',
+        type: "shape",
+        id: "cql0h8",
         left: 0,
         top: 0,
         width: 352.59259259259255,
         height: 562.5,
         viewBox: [200, 200],
-        path: 'M 0 0 L 200 0 L 200 200 L 0 200 Z',
-        fill: '{{themeColor}}',
+        path: "M 0 0 L 200 0 L 200 200 L 0 200 Z",
+        fill: "{{themeColor}}",
         fixedRatio: false,
         rotate: 0
       },
       {
-        type: 'shape',
-        id: '_RTaF4',
+        type: "shape",
+        id: "_RTaF4",
         left: 171.4814814814814,
         top: 100.13888888888887,
         width: 362.22222222222223,
         height: 362.22222222222223,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: 'rgba(255,255,255,0)',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "rgba(255,255,255,0)",
         fixedRatio: false,
         rotate: 0,
         outline: {
           width: 10,
-          color: '{{backgroundColor}}',
-          style: 'solid'
+          color: "{{backgroundColor}}",
+          style: "solid"
         }
       },
       {
-        type: 'shape',
-        id: 'UZfo8N',
+        type: "shape",
+        id: "UZfo8N",
         left: 216.66666666666663,
         top: 145.32407407407408,
         width: 271.85185185185185,
         height: 271.85185185185185,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: '{{backgroundColor}}',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "{{backgroundColor}}",
         fixedRatio: false,
         rotate: 0,
         text: {
-          content: '<p style=\'text-align: center;\'><span style=\'font-size: 80px\'>01</span></p>',
-          defaultFontName: '{{fontName}}',
-          defaultColor: '{{fontColor}}',
-          align: 'middle'
+          content: "<p style='text-align: center;'><span style='font-size: 80px'>01</span></p>",
+          defaultFontName: "{{fontName}}",
+          defaultColor: "{{fontColor}}",
+          align: "middle"
         }
       },
       {
-        type: 'text',
-        id: 'ysqtBg',
+        type: "text",
+        id: "ysqtBg",
         left: 561.4814814814814,
         top: 100.1388888888889,
         width: 359.25925925925924,
         height: 80,
-        content: '<p style=\'\'><strong><span style=\'font-size: 40px\'>在此处输入标题</span></strong></p>',
+        content: "<p style=''><strong><span style='font-size: 40px'>在此处输入标题</span></strong></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}"
       },
       {
-        type: 'text',
-        id: 'lXsoHa',
+        type: "text",
+        id: "lXsoHa",
         left: 572.5925925925925,
         top: 202.3611111111111,
         width: 257.77777777777777,
         height: 260,
-        content: '<ol><li><p style=\'\'>在此处输入内容</p></li><li><p style=\'\'>在此处输入内容</p></li><li><p style=\'\'>在此处输入内容</p></li><li><p style=\'\'>在此处输入内容</p></li><li><p style=\'\'>在此处输入内容</p></li><li><p style=\'\'>在此处输入内容</p></li></ol>',
+        content:
+          "<ol><li><p style=''>在此处输入内容</p></li><li><p style=''>在此处输入内容</p></li><li><p style=''>在此处输入内容</p></li><li><p style=''>在此处输入内容</p></li><li><p style=''>在此处输入内容</p></li><li><p style=''>在此处输入内容</p></li></ol>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
         lineHeight: 2,
-        fill: '{{subColor}}'
+        fill: "{{subColor}}"
       }
     ],
     background: {
-      type: 'solid',
-      color: '{{backgroundColor}}',
-    },
+      type: "solid",
+      color: "{{backgroundColor}}"
+    }
   },
   {
-    id: 'template',
+    id: "template",
     elements: [
       {
-        type: 'shape',
-        id: 'EBBnTr',
+        type: "shape",
+        id: "EBBnTr",
         left: 360.5996472663139,
         top: 141.8496472663139,
         width: 278.80070546737215,
         height: 278.80070546737215,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: '{{themeColor}}',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "{{themeColor}}",
         fixedRatio: true,
         rotate: 0,
         outline: {
           width: 0,
-          color: '{{backgroundColor}}',
-          style: 'solid'
+          color: "{{backgroundColor}}",
+          style: "solid"
         }
-      }, 
+      },
       {
-        type: 'shape',
-        id: 'gDIWDH',
+        type: "shape",
+        id: "gDIWDH",
         left: 456.4373897707231,
         top: 98.287037037037,
         width: 87.12522045855381,
         height: 87.12522045855381,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: '{{themeColor}}',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "{{themeColor}}",
         fixedRatio: true,
         rotate: 0,
         outline: {
           width: 4,
-          color: '{{backgroundColor}}',
-          style: 'solid'
+          color: "{{backgroundColor}}",
+          style: "solid"
         },
         text: {
-          content: '<p style=\'text-align: center;\'><span style=\'color: #ffffff;\'>1</span></p>',
-          defaultFontName: '{{fontName}}',
-          defaultColor: '{{fontColor}}',
-          align: 'middle'
+          content: "<p style='text-align: center;'><span style='color: #ffffff;'>1</span></p>",
+          defaultFontName: "{{fontName}}",
+          defaultColor: "{{fontColor}}",
+          align: "middle"
         }
-      }, 
+      },
       {
-        type: 'shape',
-        id: 'DUWT7E',
+        type: "shape",
+        id: "DUWT7E",
         left: 317.037037037037,
         top: 237.68738977072314,
         width: 87.12522045855381,
         height: 87.12522045855381,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: '{{themeColor}}',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "{{themeColor}}",
         fixedRatio: true,
         rotate: 0,
         outline: {
           width: 4,
-          color: '{{backgroundColor}}',
-          style: 'solid'
+          color: "{{backgroundColor}}",
+          style: "solid"
         },
         text: {
-          content: '<p style=\'text-align: center;\'><span style=\'color: #ffffff;\'>4</span></p>',
-          defaultFontName: '{{fontName}}',
-          defaultColor: '{{fontColor}}',
-          align: 'middle'
+          content: "<p style='text-align: center;'><span style='color: #ffffff;'>4</span></p>",
+          defaultFontName: "{{fontName}}",
+          defaultColor: "{{fontColor}}",
+          align: "middle"
         }
-      }, 
+      },
       {
-        type: 'shape',
-        id: 'pbhn38',
+        type: "shape",
+        id: "pbhn38",
         left: 456.43738977072303,
         top: 377.08774250440916,
         width: 87.12522045855381,
         height: 87.12522045855381,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: '{{themeColor}}',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "{{themeColor}}",
         fixedRatio: true,
         rotate: 0,
         outline: {
           width: 4,
-          color: '{{backgroundColor}}',
-          style: 'solid'
+          color: "{{backgroundColor}}",
+          style: "solid"
         },
         text: {
-          content: '<p style=\'text-align: center;\'><span style=\'color: #ffffff;\'>3</span></p>',
-          defaultFontName: '{{fontName}}',
-          defaultColor: '{{fontColor}}',
-          align: 'middle'
+          content: "<p style='text-align: center;'><span style='color: #ffffff;'>3</span></p>",
+          defaultFontName: "{{fontName}}",
+          defaultColor: "{{fontColor}}",
+          align: "middle"
         }
-      }, 
+      },
       {
-        type: 'shape',
-        id: 'CvMKrO',
+        type: "shape",
+        id: "CvMKrO",
         left: 595.8377425044091,
         top: 237.6873897707231,
         width: 87.12522045855381,
         height: 87.12522045855381,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: '{{themeColor}}',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "{{themeColor}}",
         fixedRatio: true,
         rotate: 0,
         outline: {
           width: 4,
-          color: '{{backgroundColor}}',
-          style: 'solid'
+          color: "{{backgroundColor}}",
+          style: "solid"
         },
         text: {
-          content: '<p style=\'text-align: center;\'><span style=\'color: #ffffff;\'>2</span></p>',
-          defaultFontName: '{{fontName}}',
-          defaultColor: '{{fontColor}}',
-          align: 'middle'
+          content: "<p style='text-align: center;'><span style='color: #ffffff;'>2</span></p>",
+          defaultFontName: "{{fontName}}",
+          defaultColor: "{{fontColor}}",
+          align: "middle"
         }
-      }, 
+      },
       {
-        type: 'text',
-        id: 'adudHB',
+        type: "text",
+        id: "adudHB",
         left: 402.962962962963,
         top: 39.39814814814815,
         width: 194.07407407407408,
         height: 50,
-        content: '<p style=\'text-align: center;\'>在此输入内容</p>',
+        content: "<p style='text-align: center;'>在此输入内容</p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-      }, 
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}"
+      },
       {
-        type: 'text',
-        id: '9UpDwg',
+        type: "text",
+        id: "9UpDwg",
         left: 402.962962962963,
         top: 473.1018518518518,
         width: 194.07407407407408,
         height: 50,
-        content: '<p style=\'text-align: center;\'>在此输入内容</p>',
+        content: "<p style='text-align: center;'>在此输入内容</p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-      }, 
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}"
+      },
       {
-        type: 'text',
-        id: 'GERdpB',
+        type: "text",
+        id: "GERdpB",
         left: 111.48148148148151,
         top: 256.25,
         width: 194.07407407407408,
         height: 50,
-        content: '<p style=\'text-align: center;\'>在此输入内容</p>',
+        content: "<p style='text-align: center;'>在此输入内容</p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-      }, 
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}"
+      },
       {
-        type: 'text',
-        id: 'G5qoho',
+        type: "text",
+        id: "G5qoho",
         left: 691.1111111111111,
         top: 256.25,
         width: 194.07407407407408,
         height: 50,
-        content: '<p style=\'text-align: center;\'>在此输入内容</p>',
+        content: "<p style='text-align: center;'>在此输入内容</p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-      }, 
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}"
+      },
       {
-        type: 'shape',
-        id: 'vdZcI6',
+        type: "shape",
+        id: "vdZcI6",
         left: 415.18518518518516,
         top: 196.4351851851852,
         width: 169.62962962962962,
         height: 169.62962962962962,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: '{{backgroundColor}}',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "{{backgroundColor}}",
         fixedRatio: false,
         rotate: 0
       }
     ],
     background: {
-      type: 'solid',
-      color: '{{backgroundColor}}',
-    },
+      type: "solid",
+      color: "{{backgroundColor}}"
+    }
   },
   {
-    id: 'template',
+    id: "template",
     elements: [
       {
-        type: 'shape',
-        id: 'tYUmrx',
+        type: "shape",
+        id: "tYUmrx",
         left: 156.66666666666683,
         top: 149.02777777777771,
         width: 264.4444444444445,
         height: 264.4444444444445,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: '{{themeColor}}',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "{{themeColor}}",
         fixedRatio: false,
         rotate: 0,
         text: {
-          content: '<p style=\'text-align: center;\'><span style=\'color: #ffffff;\'><span style=\'font-size: 60px\'>01</span></span></p>',
-          defaultFontName: '{{fontName}}',
-          defaultColor: '{{fontColor}}',
-          align: 'middle'
+          content: "<p style='text-align: center;'><span style='color: #ffffff;'><span style='font-size: 60px'>01</span></span></p>",
+          defaultFontName: "{{fontName}}",
+          defaultColor: "{{fontColor}}",
+          align: "middle"
         }
       },
       {
-        type: 'shape',
-        id: '0GVHf8',
+        type: "shape",
+        id: "0GVHf8",
         left: 342.2222222222223,
         top: 217.17592592592587,
         width: 128.14814814814812,
         height: 128.14814814814812,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: '{{backgroundColor}}',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "{{backgroundColor}}",
         fixedRatio: false,
         rotate: 0
       },
       {
-        type: 'text',
-        id: 'BO33Sv',
+        type: "text",
+        id: "BO33Sv",
         left: 378.8888888888889,
         top: 235.24999999999994,
         width: 464.4444444444444,
         height: 92,
-        content: '<p style=\'\'><strong><span style=\'font-size: 48px\'>在此处添加标题</span></strong></p>',
+        content: "<p style=''><strong><span style='font-size: 48px'>在此处添加标题</span></strong></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}"
       }
     ],
     background: {
-      type: 'solid',
-      color: '{{backgroundColor}}',
-    },
+      type: "solid",
+      color: "{{backgroundColor}}"
+    }
   },
   {
-    id: 'template',
+    id: "template",
     elements: [
       {
-        type: 'text',
-        id: 'Hj7ttp',
+        type: "text",
+        id: "Hj7ttp",
         left: 69.35185185185185,
         top: 49.21759259259262,
         width: 420,
         height: 63,
         lineHeight: 1.2,
-        content: '<p style=\'text-align: center;\'><strong><span style=\'color: #ffffff;\'><span style=\'font-size: 36px\'>1.请输入标题</span></span></strong></p>',
+        content:
+          "<p style='text-align: center;'><strong><span style='color: #ffffff;'><span style='font-size: 36px'>1.请输入标题</span></span></strong></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{themeColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{themeColor}}"
       },
       {
-        type: 'text',
-        id: 'FmKMNB',
+        type: "text",
+        id: "FmKMNB",
         left: 69.35185185185185,
         top: 129.28240740740745,
         width: 420,
         height: 384,
-        content: '<p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p>',
+        content:
+          "<p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
       },
       {
-        type: 'text',
-        id: 'rI7ZeO',
+        type: "text",
+        id: "rI7ZeO",
         left: 510.64814814814815,
         top: 49.21759259259262,
         width: 420,
         height: 63,
         lineHeight: 1.2,
-        content: '<p style=\'text-align: center;\'><strong><span style=\'color: #ffffff;\'><span style=\'font-size: 36px\'>2.请输入标题</span></span></strong></p>',
+        content:
+          "<p style='text-align: center;'><strong><span style='color: #ffffff;'><span style='font-size: 36px'>2.请输入标题</span></span></strong></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{themeColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{themeColor}}"
       },
       {
-        type: 'text',
-        id: 'KspwGc',
+        type: "text",
+        id: "KspwGc",
         left: 510.64814814814815,
         top: 129.28240740740745,
         width: 420,
         height: 384,
-        content: '<p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 24px\'>在此处输入内容</span></p>',
+        content:
+          "<p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 24px'>在此处输入内容</span></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}',
-      },
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
+      }
     ],
     background: {
-      type: 'solid',
-      color: '{{backgroundColor}}',
-    },
+      type: "solid",
+      color: "{{backgroundColor}}"
+    }
   },
   {
-    id: 'template',
+    id: "template",
     elements: [
       {
-        type: 'text',
-        id: 'Rx63Jo',
+        type: "text",
+        id: "Rx63Jo",
         left: 69.35185185185179,
         top: 51.71759259259262,
         width: 420,
         height: 58,
         lineHeight: 1.2,
-        content: '<p style=\'text-align: center;\'><strong><span style=\'color: #ffffff;\'><span style=\'font-size: 32px\'>1.请输入标题</span></span></strong></p>',
+        content:
+          "<p style='text-align: center;'><strong><span style='color: #ffffff;'><span style='font-size: 32px'>1.请输入标题</span></span></strong></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{themeColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{themeColor}}"
       },
       {
-        type: 'text',
-        id: 'ulyuzE',
+        type: "text",
+        id: "ulyuzE",
         left: 69.35185185185179,
         top: 131.78240740740745,
         width: 420,
         height: 129,
-        content: '<p style=\'text-align: center;\'><span style=\'font-size: 22px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 22px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 22px\'>在此处输入内容</span></p>',
+        content:
+          "<p style='text-align: center;'><span style='font-size: 22px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 22px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 22px'>在此处输入内容</span></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
       },
       {
-        type: 'text',
-        id: 'kr35Ca',
+        type: "text",
+        id: "kr35Ca",
         left: 510.6481481481481,
         top: 51.71759259259262,
         width: 420,
         height: 58,
         lineHeight: 1.2,
-        content: '<p style=\'text-align: center;\'><strong><span style=\'color: #ffffff;\'><span style=\'font-size: 32px\'>2.请输入标题</span></span></strong></p>',
+        content:
+          "<p style='text-align: center;'><strong><span style='color: #ffffff;'><span style='font-size: 32px'>2.请输入标题</span></span></strong></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{themeColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{themeColor}}"
       },
       {
-        type: 'text',
-        id: 'BNQSpC',
+        type: "text",
+        id: "BNQSpC",
         left: 510.6481481481481,
         top: 131.78240740740745,
         width: 420,
         height: 129,
-        content: '<p style=\'text-align: center;\'><span style=\'font-size: 22px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 22px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 22px\'>在此处输入内容</span></p>',
+        content:
+          "<p style='text-align: center;'><span style='font-size: 22px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 22px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 22px'>在此处输入内容</span></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
       },
       {
-        type: 'text',
-        id: 'Vr38Nu',
+        type: "text",
+        id: "Vr38Nu",
         left: 69.35185185185185,
         top: 301.71759259259255,
         width: 420,
         height: 58,
         lineHeight: 1.2,
-        content: '<p style=\'text-align: center;\'><strong><span style=\'color: #ffffff;\'><span style=\'font-size: 32px\'>3.请输入标题</span></span></strong></p>',
+        content:
+          "<p style='text-align: center;'><strong><span style='color: #ffffff;'><span style='font-size: 32px'>3.请输入标题</span></span></strong></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{themeColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{themeColor}}"
       },
       {
-        type: 'text',
-        id: 'IwKRSu',
+        type: "text",
+        id: "IwKRSu",
         left: 69.35185185185185,
         top: 381.7824074074074,
         width: 420,
         height: 129,
-        content: '<p style=\'text-align: center;\'><span style=\'font-size: 22px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 22px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 22px\'>在此处输入内容</span></p>',
+        content:
+          "<p style='text-align: center;'><span style='font-size: 22px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 22px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 22px'>在此处输入内容</span></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
       },
       {
-        type: 'text',
-        id: '0Opr1v',
+        type: "text",
+        id: "0Opr1v",
         left: 510.64814814814815,
         top: 301.71759259259255,
         width: 420,
         height: 58,
         lineHeight: 1.2,
-        content: '<p style=\'text-align: center;\'><strong><span style=\'color: #ffffff;\'><span style=\'font-size: 32px\'>4.请输入标题</span></span></strong></p>',
+        content:
+          "<p style='text-align: center;'><strong><span style='color: #ffffff;'><span style='font-size: 32px'>4.请输入标题</span></span></strong></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{themeColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{themeColor}}"
       },
       {
-        type: 'text',
-        id: '4L9Uzz',
+        type: "text",
+        id: "4L9Uzz",
         left: 510.64814814814815,
         top: 381.7824074074074,
         width: 420,
         height: 129,
-        content: '<p style=\'text-align: center;\'><span style=\'font-size: 22px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 22px\'>在此处输入内容</span></p><p style=\'text-align: center;\'><span style=\'font-size: 22px\'>在此处输入内容</span></p>',
+        content:
+          "<p style='text-align: center;'><span style='font-size: 22px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 22px'>在此处输入内容</span></p><p style='text-align: center;'><span style='font-size: 22px'>在此处输入内容</span></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}',
-      },
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
+      }
     ],
     background: {
-      type: 'solid',
-      color: '{{backgroundColor}}',
-    },
+      type: "solid",
+      color: "{{backgroundColor}}"
+    }
   },
   {
-    id: 'template',
+    id: "template",
     elements: [
       {
-        type: 'text',
-        id: 'GdEGxg',
+        type: "text",
+        id: "GdEGxg",
         left: 134.53703703703704,
         top: 127.25,
         width: 152.77777777777777,
         height: 308,
         lineHeight: 1.8,
-        content: '<p style=\'text-align: center;\'><strong><span style=\'color: #ffffff;\'><span style=\'font-size: 40px\'>请在此处输入标题</span></span></strong></p>',
+        content:
+          "<p style='text-align: center;'><strong><span style='color: #ffffff;'><span style='font-size: 40px'>请在此处输入标题</span></span></strong></p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
         wordSpace: 8,
-        fill: '{{themeColor}}',
+        fill: "{{themeColor}}"
       },
       {
-        type: 'text',
-        id: 'y5sAfw',
+        type: "text",
+        id: "y5sAfw",
         left: 332.8703703703704,
         top: 127.25,
         width: 532.5925925925926,
         height: 50,
-        content: '<blockquote><p style=\'\'>请在此处输入内容1</p></blockquote>',
+        content: "<blockquote><p style=''>请在此处输入内容1</p></blockquote>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
       },
       {
-        type: 'text',
-        id: 'VeuocM',
+        type: "text",
+        id: "VeuocM",
         left: 332.8703703703704,
         top: 212.0648148148148,
         width: 532.5925925925926,
         height: 50,
-        content: '<blockquote><p style=\'\'>请在此处输入内容2</p></blockquote>',
+        content: "<blockquote><p style=''>请在此处输入内容2</p></blockquote>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
       },
       {
-        type: 'text',
-        id: 'RyFWQe',
+        type: "text",
+        id: "RyFWQe",
         left: 332.8703703703704,
         top: 296.8796296296296,
         width: 532.5925925925926,
         height: 50,
-        content: '<blockquote><p style=\'\'>请在此处输入内容3</p></blockquote>',
+        content: "<blockquote><p style=''>请在此处输入内容3</p></blockquote>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}',
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
       },
       {
-        type: 'text',
-        id: 'Q56viI',
+        type: "text",
+        id: "Q56viI",
         left: 332.8703703703704,
         top: 381.69444444444446,
         width: 532.5925925925926,
         height: 50,
-        content: '<blockquote><p style=\'\'>请在此处输入内容4</p></blockquote>',
+        content: "<blockquote><p style=''>请在此处输入内容4</p></blockquote>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}',
-      },
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
+      }
     ],
     background: {
-      type: 'solid',
-      color: '{{backgroundColor}}',
-    },
+      type: "solid",
+      color: "{{backgroundColor}}"
+    }
   },
   {
-    id: 'template',
+    id: "template",
     elements: [
       {
-        type: 'shape',
-        id: 'SUWirT',
+        type: "shape",
+        id: "SUWirT",
         left: 73.8888888888889,
         top: 64.21296296296302,
         width: 49.629629629629626,
         height: 49.629629629629626,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: '{{themeColor}}',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "{{themeColor}}",
         fixedRatio: false,
         rotate: 0,
         text: {
-          content: '<p style=\'text-align: center;\'><span style=\'color: #ffffff;\'>1</span></p>',
-          defaultFontName: '{{fontName}}',
-          defaultColor: '{{fontColor}}',
-          align: 'middle'
+          content: "<p style='text-align: center;'><span style='color: #ffffff;'>1</span></p>",
+          defaultFontName: "{{fontName}}",
+          defaultColor: "{{fontColor}}",
+          align: "middle"
         }
-      }, 
+      },
       {
-        type: 'text',
-        id: 'YjzN1M',
+        type: "text",
+        id: "YjzN1M",
         left: 148.70370370370372,
         top: 64.21296296296302,
         width: 323.7037037037037,
         height: 120,
-        content: '<p style=\'\'>在此输入内容</p><p style=\'\'>在此输入内容</p><p style=\'\'>在此输入内容</p>',
+        content: "<p style=''>在此输入内容</p><p style=''>在此输入内容</p><p style=''>在此输入内容</p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}'
-      }, 
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
+      },
       {
-        type: 'shape',
-        id: 'fS09I7',
+        type: "shape",
+        id: "fS09I7",
         left: 527.5925925925926,
         top: 64.21296296296302,
         width: 49.629629629629626,
         height: 49.629629629629626,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: '{{themeColor}}',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "{{themeColor}}",
         fixedRatio: false,
         rotate: 0,
         text: {
-          content: '<p style=\'text-align: center;\'><span style=\'color: #ffffff;\'>2</span></p>',
-          defaultFontName: '{{fontName}}',
-          defaultColor: '{{fontColor}}',
-          align: 'middle'
+          content: "<p style='text-align: center;'><span style='color: #ffffff;'>2</span></p>",
+          defaultFontName: "{{fontName}}",
+          defaultColor: "{{fontColor}}",
+          align: "middle"
         }
-      }, 
+      },
       {
-        type: 'text',
-        id: 'qCnfB1',
+        type: "text",
+        id: "qCnfB1",
         left: 602.4074074074074,
         top: 64.21296296296302,
         width: 323.7037037037037,
         height: 120,
-        content: '<p style=\'\'>在此输入内容</p><p style=\'\'>在此输入内容</p><p style=\'\'>在此输入内容</p>',
+        content: "<p style=''>在此输入内容</p><p style=''>在此输入内容</p><p style=''>在此输入内容</p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}'
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
       },
       {
-        type: 'shape',
-        id: 'difAAT',
+        type: "shape",
+        id: "difAAT",
         left: 73.8888888888889,
         top: 221.25000000000003,
         width: 49.629629629629626,
         height: 49.629629629629626,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: '{{themeColor}}',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "{{themeColor}}",
         fixedRatio: false,
         rotate: 0,
         text: {
-          content: '<p style=\'text-align: center;\'><span style=\'color: #ffffff;\'>3</span></p>',
-          defaultFontName: '{{fontName}}',
-          defaultColor: '{{fontColor}}',
-          align: 'middle'
+          content: "<p style='text-align: center;'><span style='color: #ffffff;'>3</span></p>",
+          defaultFontName: "{{fontName}}",
+          defaultColor: "{{fontColor}}",
+          align: "middle"
         }
-      }, 
+      },
       {
-        type: 'text',
-        id: 'EUlvMo',
+        type: "text",
+        id: "EUlvMo",
         left: 148.70370370370372,
         top: 221.25000000000003,
         width: 323.7037037037037,
         height: 120,
-        content: '<p style=\'\'>在此输入内容</p><p style=\'\'>在此输入内容</p><p style=\'\'>在此输入内容</p>',
+        content: "<p style=''>在此输入内容</p><p style=''>在此输入内容</p><p style=''>在此输入内容</p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}'
-      }, 
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
+      },
       {
-        type: 'shape',
-        id: 'US_9jB',
+        type: "shape",
+        id: "US_9jB",
         left: 527.5925925925926,
         top: 221.25000000000003,
         width: 49.629629629629626,
         height: 49.629629629629626,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: '{{themeColor}}',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "{{themeColor}}",
         fixedRatio: false,
         rotate: 0,
         text: {
-          content: '<p style=\'text-align: center;\'><span style=\'color: #ffffff;\'>4</span></p>',
-          defaultFontName: '{{fontName}}',
-          defaultColor: '{{fontColor}}',
-          align: 'middle'
+          content: "<p style='text-align: center;'><span style='color: #ffffff;'>4</span></p>",
+          defaultFontName: "{{fontName}}",
+          defaultColor: "{{fontColor}}",
+          align: "middle"
         }
-      }, 
+      },
       {
-        type: 'text',
-        id: '243MnQ',
+        type: "text",
+        id: "243MnQ",
         left: 602.4074074074074,
         top: 221.25000000000003,
         width: 323.7037037037037,
         height: 120,
-        content: '<p style=\'\'>在此输入内容</p><p style=\'\'>在此输入内容</p><p style=\'\'>在此输入内容</p>',
+        content: "<p style=''>在此输入内容</p><p style=''>在此输入内容</p><p style=''>在此输入内容</p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}'
-      }, 
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
+      },
       {
-        type: 'shape',
-        id: 'Y_KUj0',
+        type: "shape",
+        id: "Y_KUj0",
         left: 73.8888888888889,
         top: 378.287037037037,
         width: 49.629629629629626,
         height: 49.629629629629626,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: '{{themeColor}}',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "{{themeColor}}",
         fixedRatio: false,
         rotate: 0,
         text: {
-          content: '<p style=\'text-align: center;\'><span style=\'color: #ffffff;\'>5</span></p>',
-          defaultFontName: '{{fontName}}',
-          defaultColor: '{{fontColor}}',
-          align: 'middle'
+          content: "<p style='text-align: center;'><span style='color: #ffffff;'>5</span></p>",
+          defaultFontName: "{{fontName}}",
+          defaultColor: "{{fontColor}}",
+          align: "middle"
         }
-      }, 
+      },
       {
-        type: 'text',
-        id: '9GglMe',
+        type: "text",
+        id: "9GglMe",
         left: 148.70370370370372,
         top: 378.287037037037,
         width: 323.7037037037037,
         height: 120,
-        content: '<p style=\'\'>在此输入内容</p><p style=\'\'>在此输入内容</p><p style=\'\'>在此输入内容</p>',
+        content: "<p style=''>在此输入内容</p><p style=''>在此输入内容</p><p style=''>在此输入内容</p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}'
-      }, 
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
+      },
       {
-        type: 'shape',
-        id: 'eSInje',
+        type: "shape",
+        id: "eSInje",
         left: 527.5925925925926,
         top: 378.287037037037,
         width: 49.629629629629626,
         height: 49.629629629629626,
         viewBox: [200, 200],
-        path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z',
-        fill: '{{themeColor}}',
+        path: "M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z",
+        fill: "{{themeColor}}",
         fixedRatio: false,
         rotate: 0,
         text: {
-          content: '<p style=\'text-align: center;\'><span style=\'color: #ffffff;\'>6</span></p>',
-          defaultFontName: '{{fontName}}',
-          defaultColor: '{{fontColor}}',
-          align: 'middle'
+          content: "<p style='text-align: center;'><span style='color: #ffffff;'>6</span></p>",
+          defaultFontName: "{{fontName}}",
+          defaultColor: "{{fontColor}}",
+          align: "middle"
         }
-      }, 
+      },
       {
-        type: 'text',
-        id: '0S3yUg',
+        type: "text",
+        id: "0S3yUg",
         left: 602.4074074074074,
         top: 378.287037037037,
         width: 323.7037037037037,
         height: 120,
-        content: '<p style=\'\'>在此输入内容</p><p style=\'\'>在此输入内容</p><p style=\'\'>在此输入内容</p>',
+        content: "<p style=''>在此输入内容</p><p style=''>在此输入内容</p><p style=''>在此输入内容</p>",
         rotate: 0,
-        defaultFontName: '{{fontName}}',
-        defaultColor: '{{fontColor}}',
-        fill: '{{subColor}}'
+        defaultFontName: "{{fontName}}",
+        defaultColor: "{{fontColor}}",
+        fill: "{{subColor}}"
       }
     ],
     background: {
-      type: 'solid',
-      color: '{{backgroundColor}}',
-    },
-  },
-]
+      type: "solid",
+      color: "{{backgroundColor}}"
+    }
+  }
+]

+ 78 - 77
src/mocks/slides.ts

@@ -1,186 +1,187 @@
-import type { Slide } from '@/types/slides'
+import type { Slide } from "@/types/slides"
 
 export const slides: Slide[] = [
   {
-    id: 'test-slide-1',
+    id: "test-slide-1",
     elements: [
       {
-        type: 'shape',
-        id: '4cbRxp',
+        type: "shape",
+        id: "4cbRxp",
         left: 0,
         top: 200,
         width: 546,
         height: 362.5,
         viewBox: [200, 200],
-        path: 'M 0 0 L 0 200 L 200 200 Z',
-        fill: '#5b9bd5',
+        path: "M 0 0 L 0 200 L 200 200 Z",
+        fill: "#5b9bd5",
         fixedRatio: false,
         opacity: 0.7,
         rotate: 0
       },
       {
-        type: 'shape',
-        id: 'ookHrf',
+        type: "shape",
+        id: "ookHrf",
         left: 0,
         top: 0,
         width: 300,
         height: 320,
         viewBox: [200, 200],
-        path: 'M 0 0 L 0 200 L 200 200 Z',
-        fill: '#5b9bd5',
+        path: "M 0 0 L 0 200 L 200 200 Z",
+        fill: "#5b9bd5",
         fixedRatio: false,
         flipV: true,
         rotate: 0
       },
       {
-        type: 'text',
-        id: 'idn7Mx',
+        type: "text",
+        id: "idn7Mx",
         left: 355,
         top: 65.25,
         width: 450,
         height: 188,
         lineHeight: 1.2,
-        content: '<p><strong><span style=\"font-size: 112px;\">PPTist</span></strong></p>',
+        content: '<p><strong><span style="font-size: 112px;">ppt</span></strong></p>',
         rotate: 0,
-        defaultFontName: 'Microsoft Yahei',
-        defaultColor: '#333'
+        defaultFontName: "Microsoft Yahei",
+        defaultColor: "#333"
       },
       {
-        type: 'text',
-        id: '7stmVP',
+        type: "text",
+        id: "7stmVP",
         left: 355,
         top: 253.25,
         width: 585,
         height: 56,
-        content: '<p><span style=\"font-size: 24px;\">基于 Vue 3.x + TypeScript 的在线演示文稿应用</span></p>',
+        content: '<p><span style="font-size: 24px;">在线演示文稿应用</span></p>',
         rotate: 0,
-        defaultFontName: 'Microsoft Yahei',
-        defaultColor: '#333'
+        defaultFontName: "Microsoft Yahei",
+        defaultColor: "#333"
       },
       {
-        type: 'line',
-        id: 'FnpZs4',
+        type: "line",
+        id: "FnpZs4",
         left: 361,
         top: 238,
         start: [0, 0],
         end: [549, 0],
-        points: ['', ''],
-        color: '#5b9bd5',
-        style: 'solid',
-        width: 2,
-      },
+        points: ["", ""],
+        color: "#5b9bd5",
+        style: "solid",
+        width: 2
+      }
     ],
     background: {
-      type: 'solid',
-      color: '#ffffff',
-    },
+      type: "solid",
+      color: "#ffffff"
+    }
   },
   {
-    id: 'test-slide-2',
+    id: "test-slide-2",
     elements: [
       {
-        type: 'text',
-        id: 'ptNnUJ',
+        type: "text",
+        id: "ptNnUJ",
         left: 145,
         top: 148,
         width: 711,
         height: 77,
         lineHeight: 1.2,
-        content: '<p style=\"text-align: center;\"><strong><span style=\"font-size: 48px;\">在此处添加标题</span></strong></p>',
+        content: '<p style="text-align: center;"><strong><span style="font-size: 48px;">在此处添加标题</span></strong></p>',
         rotate: 0,
-        defaultFontName: 'Microsoft Yahei',
-        defaultColor: '#333',
-      }, 
+        defaultFontName: "Microsoft Yahei",
+        defaultColor: "#333"
+      },
       {
-        type: 'text',
-        id: 'mRHvQN',
+        type: "text",
+        id: "mRHvQN",
         left: 207.50000000000003,
         top: 249.84259259259264,
         width: 585,
         height: 56,
-        content: '<p style=\"text-align: center;\"><span style=\"font-size: 24px;\">在此处添加副标题</span></p>',
+        content: '<p style="text-align: center;"><span style="font-size: 24px;">在此处添加副标题</span></p>',
         rotate: 0,
-        defaultFontName: 'Microsoft Yahei',
-        defaultColor: '#333',
-      }, 
+        defaultFontName: "Microsoft Yahei",
+        defaultColor: "#333"
+      },
       {
-        type: 'line',
-        id: '7CQDwc',
+        type: "line",
+        id: "7CQDwc",
         left: 323.09259259259267,
         top: 238.33333333333334,
         start: [0, 0],
         end: [354.8148148148148, 0],
-        points: ['', ''],
-        color: '#5b9bd5',
-        style: 'solid',
+        points: ["", ""],
+        color: "#5b9bd5",
+        style: "solid",
         width: 4
-      }, 
+      },
       {
-        type: 'shape',
-        id: '09wqWw',
+        type: "shape",
+        id: "09wqWw",
         left: -27.648148148148138,
         top: 432.73148148148147,
         width: 1056.2962962962963,
         height: 162.96296296296296,
         viewBox: [200, 200],
-        path: 'M 0 20 C 40 -40 60 60 100 20 C 140 -40 160 60 200 20 L 200 180 C 140 240 160 140 100 180 C 40 240 60 140 0 180 L 0 20 Z',
-        fill: '#5b9bd5',
+        path: "M 0 20 C 40 -40 60 60 100 20 C 140 -40 160 60 200 20 L 200 180 C 140 240 160 140 100 180 C 40 240 60 140 0 180 L 0 20 Z",
+        fill: "#5b9bd5",
         fixedRatio: false,
         rotate: 0
       }
     ],
     background: {
-      type: 'solid',
-      color: '#fff',
-    },
+      type: "solid",
+      color: "#fff"
+    }
   },
   {
-    id: 'test-slide-3',
+    id: "test-slide-3",
     elements: [
       {
-        type: 'shape',
-        id: 'vSheCJ',
+        type: "shape",
+        id: "vSheCJ",
         left: 183.5185185185185,
         top: 175.5092592592593,
         width: 605.1851851851851,
         height: 185.18518518518516,
         viewBox: [200, 200],
-        path: 'M 0 0 L 200 0 L 200 200 L 0 200 Z',
-        fill: '#5b9bd5',
+        path: "M 0 0 L 200 0 L 200 200 L 0 200 Z",
+        fill: "#5b9bd5",
         fixedRatio: false,
         rotate: 0
-      }, 
+      },
       {
-        type: 'shape',
-        id: 'Mpwv7x',
+        type: "shape",
+        id: "Mpwv7x",
         left: 211.29629629629628,
         top: 201.80555555555557,
         width: 605.1851851851851,
         height: 185.18518518518516,
         viewBox: [200, 200],
-        path: 'M 0 0 L 200 0 L 200 200 L 0 200 Z',
-        fill: '#5b9bd5',
+        path: "M 0 0 L 200 0 L 200 200 L 0 200 Z",
+        fill: "#5b9bd5",
         fixedRatio: false,
         rotate: 0,
         opacity: 0.7
-      }, 
+      },
       {
-        type: 'text',
-        id: 'WQOTAp',
+        type: "text",
+        id: "WQOTAp",
         left: 304.9074074074074,
         top: 198.10185185185182,
         width: 417.9629629629629,
         height: 140,
-        content: '<p style=\"text-align: center;\"><strong><span style=\"font-size: 80px;\"><span style=\"color: rgb(255, 255, 255);\">感谢观看</span></span></strong></p>',
+        content:
+          '<p style="text-align: center;"><strong><span style="font-size: 80px;"><span style="color: rgb(255, 255, 255);">感谢观看</span></span></strong></p>',
         rotate: 0,
-        defaultFontName: 'Microsoft Yahei',
-        defaultColor: '#333',
+        defaultFontName: "Microsoft Yahei",
+        defaultColor: "#333",
         wordSpace: 5
       }
     ],
     background: {
-      type: 'solid',
-      color: '#fff',
-    },
-  },
-]
+      type: "solid",
+      color: "#fff"
+    }
+  }
+]

+ 10 - 10
src/mocks/theme.ts

@@ -1,19 +1,19 @@
-import type { SlideTheme } from '@/types/slides'
+import type { SlideTheme } from "@/types/slides"
 
 export const theme: SlideTheme = {
-  themeColor: '#5b9bd5',
-  fontColor: '#333',
-  fontName: 'Microsoft Yahei',
-  backgroundColor: '#fff',
+  themeColor: "#5b9bd5",
+  fontColor: "#333",
+  fontName: "Microsoft Yahei",
+  backgroundColor: "#fff",
   shadow: {
     h: 3,
     v: 3,
     blur: 2,
-    color: '#808080',
+    color: "#808080"
   },
   outline: {
     width: 2,
-    color: '#525252',
-    style: 'solid',
-  },
-}
+    color: "#525252",
+    style: "solid"
+  }
+}

+ 4 - 2
src/plugins/icon.ts

@@ -1,6 +1,6 @@
 // https://iconpark.bytedance.com/official
 
-import type { App } from 'vue'
+import type { App } from "vue"
 import {
   PlayOne,
   FullScreenPlay,
@@ -124,7 +124,8 @@ import {
   User,
   Switch,
   More,
-} from '@icon-park/vue-next'
+  LinkCloud
+} from "@icon-park/vue-next"
 
 export interface Icons {
   [key: string]: typeof PlayOne
@@ -253,6 +254,7 @@ export const icons: Icons = {
   IconUser: User,
   IconSwitch: Switch,
   IconMore: More,
+  IconLinkCloud: LinkCloud
 }
 
 export default {

+ 260 - 235
src/types/slides.ts

@@ -1,47 +1,48 @@
 export const enum ShapePathFormulasKeys {
-  ROUND_RECT = 'roundRect',
-  ROUND_RECT_DIAGONAL = 'roundRectDiagonal',
-  ROUND_RECT_SINGLE = 'roundRectSingle',
-  ROUND_RECT_SAMESIDE = 'roundRectSameSide',
-  CUT_RECT_DIAGONAL = 'cutRectDiagonal',
-  CUT_RECT_SINGLE = 'cutRectSingle',
-  CUT_RECT_SAMESIDE = 'cutRectSameSide',
-  CUT_ROUND_RECT = 'cutRoundRect',
-  MESSAGE = 'message',
-  ROUND_MESSAGE = 'roundMessage',
-  L = 'L',
-  RING_RECT = 'ringRect',
-  PLUS = 'plus',
-  TRIANGLE = 'triangle',
-  PARALLELOGRAM_LEFT = 'parallelogramLeft',
-  PARALLELOGRAM_RIGHT = 'parallelogramRight',
-  TRAPEZOID = 'trapezoid',
-  BULLET = 'bullet',
-  INDICATOR = 'indicator',
+  ROUND_RECT = "roundRect",
+  ROUND_RECT_DIAGONAL = "roundRectDiagonal",
+  ROUND_RECT_SINGLE = "roundRectSingle",
+  ROUND_RECT_SAMESIDE = "roundRectSameSide",
+  CUT_RECT_DIAGONAL = "cutRectDiagonal",
+  CUT_RECT_SINGLE = "cutRectSingle",
+  CUT_RECT_SAMESIDE = "cutRectSameSide",
+  CUT_ROUND_RECT = "cutRoundRect",
+  MESSAGE = "message",
+  ROUND_MESSAGE = "roundMessage",
+  L = "L",
+  RING_RECT = "ringRect",
+  PLUS = "plus",
+  TRIANGLE = "triangle",
+  PARALLELOGRAM_LEFT = "parallelogramLeft",
+  PARALLELOGRAM_RIGHT = "parallelogramRight",
+  TRAPEZOID = "trapezoid",
+  BULLET = "bullet",
+  INDICATOR = "indicator"
 }
 
 export const enum ElementTypes {
-  TEXT = 'text',
-  IMAGE = 'image',
-  SHAPE = 'shape',
-  LINE = 'line',
-  CHART = 'chart',
-  TABLE = 'table',
-  LATEX = 'latex',
-  VIDEO = 'video',
-  AUDIO = 'audio',
+  TEXT = "text",
+  IMAGE = "image",
+  SHAPE = "shape",
+  LINE = "line",
+  CHART = "chart",
+  TABLE = "table",
+  LATEX = "latex",
+  VIDEO = "video",
+  AUDIO = "audio",
+  CLOUDCOACH = "cloudCoach"
 }
 
 /**
  * 渐变
- * 
+ *
  * type: 渐变类型(径向、线性)
- * 
+ *
  * colors: 渐变颜色列表(pos: 百分比位置;color: 颜色)
- * 
+ *
  * rotate: 渐变角度(线性渐变)
  */
-export type GradientType = 'linear' | 'radial'
+export type GradientType = "linear" | "radial"
 export type GradientColor = {
   pos: number
   color: string
@@ -54,13 +55,13 @@ export interface Gradient {
 
 /**
  * 元素阴影
- * 
+ *
  * h: 水平偏移量
- * 
+ *
  * v: 垂直偏移量
- * 
+ *
  * blur: 模糊程度
- * 
+ *
  * color: 阴影颜色
  */
 export interface PPTElementShadow {
@@ -72,26 +73,26 @@ export interface PPTElementShadow {
 
 /**
  * 元素边框
- * 
+ *
  * style?: 边框样式(实线或虚线)
- * 
+ *
  * width?: 边框宽度
- * 
+ *
  * color?: 边框颜色
  */
 export interface PPTElementOutline {
-  style?: 'dashed' | 'solid' | 'dotted'
+  style?: "dashed" | "solid" | "dotted"
   width?: number
   color?: string
 }
 
-export type ElementLinkType = 'web' | 'slide'
+export type ElementLinkType = "web" | "slide"
 
 /**
  * 元素超链接
- * 
+ *
  * type: 链接类型(网页、幻灯片页面)
- * 
+ *
  * target: 目标地址(网页链接、幻灯片页面ID)
  */
 export interface PPTElementLink {
@@ -99,28 +100,27 @@ export interface PPTElementLink {
   target: string
 }
 
-
 /**
  * 元素通用属性
- * 
+ *
  * id: 元素ID
- * 
+ *
  * left: 元素水平方向位置(距离画布左侧)
- * 
+ *
  * top: 元素垂直方向位置(距离画布顶部)
- * 
+ *
  * lock?: 锁定元素
- * 
+ *
  * groupId?: 组合ID(拥有相同组合ID的元素即为同一组合元素成员)
- * 
+ *
  * width: 元素宽度
- * 
+ *
  * height: 元素高度
- * 
+ *
  * rotate: 旋转角度
- * 
+ *
  * link?: 超链接
- * 
+ *
  * name?: 元素名
  */
 interface PPTBaseElement {
@@ -136,36 +136,35 @@ interface PPTBaseElement {
   name?: string
 }
 
-
 /**
  * 文本元素
- * 
+ *
  * type: 元素类型(text)
- * 
+ *
  * content: 文本内容(HTML字符串)
- * 
+ *
  * defaultFontName: 默认字体(会被文本内容中的HTML内联样式覆盖)
- * 
+ *
  * defaultColor: 默认颜色(会被文本内容中的HTML内联样式覆盖)
- * 
+ *
  * outline?: 边框
- * 
+ *
  * fill?: 填充色
- * 
+ *
  * lineHeight?: 行高(倍),默认1.5
- * 
+ *
  * wordSpace?: 字间距,默认0
- * 
+ *
  * opacity?: 不透明度,默认1
- * 
+ *
  * shadow?: 阴影
- * 
+ *
  * paragraphSpace?: 段间距,默认 5px
- * 
+ *
  * vertical?: 竖向文本
  */
 export interface PPTTextElement extends PPTBaseElement {
-  type: 'text'
+  type: "text"
   content: string
   defaultFontName: string
   defaultColor: string
@@ -179,12 +178,11 @@ export interface PPTTextElement extends PPTBaseElement {
   vertical?: boolean
 }
 
-
 /**
  * 图片翻转、形状翻转
- * 
+ *
  * flipH?: 水平翻转
- * 
+ *
  * flipV?: 垂直翻转
  */
 export interface ImageOrShapeFlip {
@@ -194,44 +192,44 @@ export interface ImageOrShapeFlip {
 
 /**
  * 图片滤镜
- * 
+ *
  * https://developer.mozilla.org/zh-CN/docs/Web/CSS/filter
- * 
+ *
  * 'blur'?: 模糊,默认0(px)
- * 
+ *
  * 'brightness'?: 亮度,默认100(%)
- * 
+ *
  * 'contrast'?: 对比度,默认100(%)
- * 
+ *
  * 'grayscale'?: 灰度,默认0(%)
- * 
+ *
  * 'saturate'?: 饱和度,默认100(%)
- * 
+ *
  * 'hue-rotate'?: 色相旋转,默认0(deg)
- * 
+ *
  * 'opacity'?: 不透明度,默认100(%)
  */
-export type ImageElementFilterKeys = 'blur' | 'brightness' | 'contrast' | 'grayscale' | 'saturate' | 'hue-rotate' | 'opacity' | 'sepia' | 'invert'
+export type ImageElementFilterKeys = "blur" | "brightness" | "contrast" | "grayscale" | "saturate" | "hue-rotate" | "opacity" | "sepia" | "invert"
 export interface ImageElementFilters {
-  'blur'?: string
-  'brightness'?: string
-  'contrast'?: string
-  'grayscale'?: string
-  'saturate'?: string
-  'hue-rotate'?: string
-  'sepia'?: string
-  'invert'?: string
-  'opacity'?: string
+  blur?: string
+  brightness?: string
+  contrast?: string
+  grayscale?: string
+  saturate?: string
+  "hue-rotate"?: string
+  sepia?: string
+  invert?: string
+  opacity?: string
 }
 
 export type ImageClipDataRange = [[number, number], [number, number]]
 
 /**
  * 图片裁剪
- * 
+ *
  * range: 裁剪范围,例如:[[10, 10], [90, 90]] 表示裁取原图从左上角 10%, 10% 到 90%, 90% 的范围
- * 
- * shape: 裁剪形状,见 configs/imageClip.ts CLIPPATHS 
+ *
+ * shape: 裁剪形状,见 configs/imageClip.ts CLIPPATHS
  */
 export interface ImageElementClip {
   range: ImageClipDataRange
@@ -240,31 +238,31 @@ export interface ImageElementClip {
 
 /**
  * 图片元素
- * 
+ *
  * type: 元素类型(image)
- * 
+ *
  * fixedRatio: 固定图片宽高比例
- * 
+ *
  * src: 图片地址
- * 
+ *
  * outline?: 边框
- * 
+ *
  * filters?: 图片滤镜
- * 
+ *
  * clip?: 裁剪信息
- * 
+ *
  * flipH?: 水平翻转
- * 
+ *
  * flipV?: 垂直翻转
- * 
+ *
  * shadow?: 阴影
- * 
+ *
  * radius?: 圆角半径
- * 
+ *
  * colorMask?: 颜色蒙版
  */
 export interface PPTImageElement extends PPTBaseElement {
-  type: 'image'
+  type: "image"
   fixedRatio: boolean
   src: string
   outline?: PPTElementOutline
@@ -277,17 +275,17 @@ export interface PPTImageElement extends PPTBaseElement {
   colorMask?: string
 }
 
-export type ShapeTextAlign = 'top' | 'middle' | 'bottom' 
+export type ShapeTextAlign = "top" | "middle" | "bottom"
 
 /**
  * 形状内文本
- * 
+ *
  * content: 文本内容(HTML字符串)
- * 
+ *
  * defaultFontName: 默认字体(会被文本内容中的HTML内联样式覆盖)
- * 
+ *
  * defaultColor: 默认颜色(会被文本内容中的HTML内联样式覆盖)
- * 
+ *
  * align: 文本对齐方向(垂直方向)
  */
 export interface ShapeText {
@@ -299,41 +297,41 @@ export interface ShapeText {
 
 /**
  * 形状元素
- * 
+ *
  * type: 元素类型(shape)
- * 
+ *
  * viewBox: SVG的viewBox属性,例如 [1000, 1000] 表示 '0 0 1000 1000'
- * 
+ *
  * path: 形状路径,SVG path 的 d 属性
- * 
+ *
  * fixedRatio: 固定形状宽高比例
- * 
+ *
  * fill: 填充,不存在渐变时生效
- * 
+ *
  * gradient?: 渐变,该属性存在时将优先作为填充
- * 
+ *
  * outline?: 边框
- * 
+ *
  * opacity?: 不透明度
- * 
+ *
  * flipH?: 水平翻转
- * 
+ *
  * flipV?: 垂直翻转
- * 
+ *
  * shadow?: 阴影
- * 
+ *
  * special?: 特殊形状(标记一些难以解析的形状,例如路径使用了 L Q C A 以外的类型,该类形状在导出后将变为图片的形式)
- * 
+ *
  * text?: 形状内文本
- * 
+ *
  * pathFormula?: 形状路径计算公式
  * 一般情况下,形状的大小变化时仅由宽高基于 viewBox 的缩放比例来调整形状,而 viewBox 本身和 path 不会变化,
  * 但也有一些形状希望能更精确的控制一些关键点的位置,此时就需要提供路径计算公式,通过在缩放时更新 viewBox 并重新计算 path 来重新绘制形状
- * 
+ *
  * keypoints?: 关键点位置百分比
  */
 export interface PPTShapeElement extends PPTBaseElement {
-  type: 'shape'
+  type: "shape"
   viewBox: [number, number]
   path: string
   fixedRatio: boolean
@@ -350,39 +348,38 @@ export interface PPTShapeElement extends PPTBaseElement {
   keypoints?: number[]
 }
 
-
-export type LinePoint = '' | 'arrow' | 'dot' 
+export type LinePoint = "" | "arrow" | "dot"
 
 /**
  * 线条元素
- * 
+ *
  * type: 元素类型(line)
- * 
+ *
  * start: 起点位置([x, y])
- * 
+ *
  * end: 终点位置([x, y])
- * 
+ *
  * style: 线条样式(实线、虚线、点线)
- * 
+ *
  * color: 线条颜色
- * 
+ *
  * points: 端点样式([起点样式, 终点样式],可选:无、箭头、圆点)
- * 
+ *
  * shadow?: 阴影
- * 
+ *
  * broken?: 折线控制点位置([x, y])
- * 
+ *
  * broken2?: 双折线控制点位置([x, y])
- * 
+ *
  * curve?: 二次曲线控制点位置([x, y])
- * 
+ *
  * cubic?: 三次曲线控制点位置([[x1, y1], [x2, y2]])
  */
-export interface PPTLineElement extends Omit<PPTBaseElement, 'height' | 'rotate'> {
-  type: 'line'
+export interface PPTLineElement extends Omit<PPTBaseElement, "height" | "rotate"> {
+  type: "line"
   start: [number, number]
   end: [number, number]
-  style: 'solid' | 'dashed' | 'dotted'
+  style: "solid" | "dashed" | "dotted"
   color: string
   points: [LinePoint, LinePoint]
   shadow?: PPTElementShadow
@@ -392,8 +389,7 @@ export interface PPTLineElement extends Omit<PPTBaseElement, 'height' | 'rotate'
   cubic?: [[number, number], [number, number]]
 }
 
-
-export type ChartType = 'bar' | 'column' | 'line' | 'pie' | 'ring' | 'area' | 'radar' | 'scatter'
+export type ChartType = "bar" | "column" | "line" | "pie" | "ring" | "area" | "radar" | "scatter"
 
 export interface ChartOptions {
   lineSmooth?: boolean
@@ -408,25 +404,25 @@ export interface ChartData {
 
 /**
  * 图表元素
- * 
+ *
  * type: 元素类型(chart)
- * 
+ *
  * fill?: 填充色
- * 
+ *
  * chartType: 图表基础类型(bar/line/pie),所有图表类型都是由这三种基本类型衍生而来
- * 
+ *
  * data: 图表数据
- * 
+ *
  * options: 扩展选项
- * 
+ *
  * outline?: 边框
- * 
+ *
  * themeColors: 主题色
- * 
+ *
  * textColor?: 文字颜色
  */
 export interface PPTChartElement extends PPTBaseElement {
-  type: 'chart'
+  type: "chart"
   fill?: string
   chartType: ChartType
   data: ChartData
@@ -436,27 +432,26 @@ export interface PPTChartElement extends PPTBaseElement {
   textColor?: string
 }
 
-
-export type TextAlign = 'left' | 'center' | 'right' | 'justify'
+export type TextAlign = "left" | "center" | "right" | "justify"
 /**
  * 表格单元格样式
- * 
+ *
  * bold?: 加粗
- * 
+ *
  * em?: 斜体
- * 
+ *
  * underline?: 下划线
- * 
+ *
  * strikethrough?: 删除线
- * 
+ *
  * color?: 字体颜色
- * 
+ *
  * backcolor?: 填充色
- * 
+ *
  * fontsize?: 字体大小
- * 
+ *
  * fontname?: 字体
- * 
+ *
  * align?: 对齐方式
  */
 export interface TableCellStyle {
@@ -471,18 +466,17 @@ export interface TableCellStyle {
   align?: TextAlign
 }
 
-
 /**
  * 表格单元格
- * 
+ *
  * id: 单元格ID
- * 
+ *
  * colspan: 合并列数
- * 
+ *
  * rowspan: 合并行数
- * 
+ *
  * text: 文字内容
- * 
+ *
  * style?: 单元格样式
  */
 export interface TableCell {
@@ -495,15 +489,15 @@ export interface TableCell {
 
 /**
  * 表格主题
- * 
+ *
  * color: 主题色
- * 
+ *
  * rowHeader: 标题行
- * 
+ *
  * rowFooter: 汇总行
- * 
+ *
  * colHeader: 第一列
- * 
+ *
  * colFooter: 最后一列
  */
 export interface TableTheme {
@@ -516,21 +510,21 @@ export interface TableTheme {
 
 /**
  * 表格元素
- * 
+ *
  * type: 元素类型(table)
- * 
+ *
  * outline: 边框
- * 
+ *
  * theme?: 主题
- * 
+ *
  * colWidths: 列宽数组,如[30, 50, 20]表示三列宽度分别为30%, 50%, 20%
- * 
+ *
  * cellMinHeight: 单元格最小高度
- * 
+ *
  * data: 表格数据
  */
 export interface PPTTableElement extends PPTBaseElement {
-  type: 'table'
+  type: "table"
   outline: PPTElementOutline
   theme?: TableTheme
   colWidths: number[]
@@ -538,26 +532,25 @@ export interface PPTTableElement extends PPTBaseElement {
   data: TableCell[][]
 }
 
-
 /**
  * LaTeX元素(公式)
- * 
+ *
  * type: 元素类型(latex)
- * 
+ *
  * latex: latex代码
- * 
+ *
  * path: svg path
- * 
+ *
  * color: 颜色
- * 
+ *
  * strokeWidth: 路径宽度
- * 
+ *
  * viewBox: SVG的viewBox属性
- * 
+ *
  * fixedRatio: 固定形状宽高比例
  */
 export interface PPTLatexElement extends PPTBaseElement {
-  type: 'latex'
+  type: "latex"
   latex: string
   path: string
   color: string
@@ -568,19 +561,19 @@ export interface PPTLatexElement extends PPTBaseElement {
 
 /**
  * 视频元素
- * 
+ *
  * type: 元素类型(video)
- * 
+ *
  * src: 视频地址
- * 
+ *
  * autoplay: 自动播放
- * 
+ *
  * poster: 预览封面
- * 
+ *
  * ext: 视频后缀,当资源链接缺少后缀时用该字段确认资源类型
  */
 export interface PPTVideoElement extends PPTBaseElement {
-  type: 'video'
+  type: "video"
   src: string
   autoplay: boolean
   poster?: string
@@ -589,23 +582,23 @@ export interface PPTVideoElement extends PPTBaseElement {
 
 /**
  * 音频元素
- * 
+ *
  * type: 元素类型(audio)
- * 
+ *
  * fixedRatio: 固定图标宽高比例
- * 
+ *
  * color: 图标颜色
- * 
+ *
  * loop: 循环播放
- * 
+ *
  * autoplay: 自动播放
- * 
+ *
  * src: 音频地址
- * 
+ *
  * ext: 音频后缀,当资源链接缺少后缀时用该字段确认资源类型
  */
 export interface PPTAudioElement extends PPTBaseElement {
-  type: 'audio'
+  type: "audio"
   fixedRatio: boolean
   color: string
   loop: boolean
@@ -614,25 +607,46 @@ export interface PPTAudioElement extends PPTBaseElement {
   ext?: string
 }
 
-
-export type PPTElement = PPTTextElement | PPTImageElement | PPTShapeElement | PPTLineElement | PPTChartElement | PPTTableElement | PPTLatexElement | PPTVideoElement | PPTAudioElement
-
-export type AnimationType = 'in' | 'out' | 'attention'
-export type AnimationTrigger = 'click' | 'meantime' | 'auto'
+/**
+ * 云教练元素
+ *
+ * type: 元素类型(cloudCoach)
+ *
+ * url: 云练习地址
+ */
+export interface PPTCloudCoachElement extends PPTBaseElement {
+  type: "cloudCoach"
+  url: string
+}
+
+export type PPTElement =
+  | PPTTextElement
+  | PPTImageElement
+  | PPTShapeElement
+  | PPTLineElement
+  | PPTChartElement
+  | PPTTableElement
+  | PPTLatexElement
+  | PPTVideoElement
+  | PPTAudioElement
+  | PPTCloudCoachElement
+
+export type AnimationType = "in" | "out" | "attention"
+export type AnimationTrigger = "click" | "meantime" | "auto"
 
 /**
  * 元素动画
- * 
+ *
  * id: 动画id
- * 
+ *
  * elId: 元素ID
- * 
+ *
  * effect: 动画效果
- * 
+ *
  * type: 动画类型(入场、退场、强调)
- * 
+ *
  * duration: 动画持续时间
- * 
+ *
  * trigger: 动画触发方式(click - 单击时、meantime - 与上一动画同时、auto - 上一动画之后)
  */
 export interface PPTAnimation {
@@ -644,22 +658,22 @@ export interface PPTAnimation {
   trigger: AnimationTrigger
 }
 
-export type SlideBackgroundType = 'solid' | 'image' | 'gradient'
-export type SlideBackgroundImageSize = 'cover' | 'contain' | 'repeat'
+export type SlideBackgroundType = "solid" | "image" | "gradient"
+export type SlideBackgroundImageSize = "cover" | "contain" | "repeat"
 export interface SlideBackgroundImage {
   src: string
-  size: SlideBackgroundImageSize,
+  size: SlideBackgroundImageSize
 }
 
 /**
  * 幻灯片背景
- * 
+ *
  * type: 背景类型(纯色、图片、渐变)
- * 
+ *
  * color?: 背景颜色(纯色)
- * 
+ *
  * image?: 图片背景
- * 
+ *
  * gradientType?: 渐变背景
  */
 export interface SlideBackground {
@@ -669,8 +683,19 @@ export interface SlideBackground {
   gradient?: Gradient
 }
 
-
-export type TurningMode = 'no' | 'fade' | 'slideX' | 'slideY' | 'random' | 'slideX3D' | 'slideY3D' | 'rotate' | 'scaleY' | 'scaleX' | 'scale' | 'scaleReverse'
+export type TurningMode =
+  | "no"
+  | "fade"
+  | "slideX"
+  | "slideY"
+  | "random"
+  | "slideX3D"
+  | "slideY3D"
+  | "rotate"
+  | "scaleY"
+  | "scaleX"
+  | "scale"
+  | "scaleReverse"
 
 export interface NoteReply {
   id: string
@@ -695,19 +720,19 @@ export interface SectionTag {
 
 /**
  * 幻灯片页面
- * 
+ *
  * id: 页面ID
- * 
+ *
  * elements: 元素集合
- * 
+ *
  * notes: 批注
- * 
+ *
  * remark?: 备注
- * 
+ *
  * background?: 页面背景
- * 
+ *
  * animations?: 元素动画集合
- * 
+ *
  * turningMode?: 翻页方式
  */
 export interface Slide {
@@ -723,13 +748,13 @@ export interface Slide {
 
 /**
  * 幻灯片主题
- * 
+ *
  * backgroundColor: 页面背景颜色
- * 
+ *
  * themeColor: 主题色,用于默认创建的形状颜色等
- * 
+ *
  * fontColor: 字体颜色
- * 
+ *
  * fontName: 字体
  */
 export interface SlideTheme {

+ 76 - 77
src/views/Editor/Canvas/EditableElement.vue

@@ -1,45 +1,41 @@
 <template>
-  <div 
+  <div
     class="editable-element"
     ref="elementRef"
     :id="`editable-element-${elementInfo.id}`"
     :style="{
-      zIndex: elementIndex,
+      zIndex: elementIndex
     }"
   >
-    <component
-      :is="currentElementComponent"
-      :elementInfo="elementInfo"
-      :selectElement="selectElement"
-      :contextmenus="contextmenus"
-    ></component>
+    <component :is="currentElementComponent" :elementInfo="elementInfo" :selectElement="selectElement" :contextmenus="contextmenus" />
   </div>
 </template>
 
 <script lang="ts" setup>
-import { computed } from 'vue'
-import { ElementTypes, type PPTElement } from '@/types/slides'
-import type { ContextmenuItem } from '@/components/Contextmenu/types'
+import { computed } from "vue"
+import { ElementTypes, type PPTElement } from "@/types/slides"
+import type { ContextmenuItem } from "@/components/Contextmenu/types"
 
-import useLockElement from '@/hooks/useLockElement'
-import useDeleteElement from '@/hooks/useDeleteElement'
-import useCombineElement from '@/hooks/useCombineElement'
-import useOrderElement from '@/hooks/useOrderElement'
-import useAlignElementToCanvas from '@/hooks/useAlignElementToCanvas'
-import useCopyAndPasteElement from '@/hooks/useCopyAndPasteElement'
-import useSelectElement from '@/hooks/useSelectElement'
+import useLockElement from "@/hooks/useLockElement"
+import useDeleteElement from "@/hooks/useDeleteElement"
+import useCombineElement from "@/hooks/useCombineElement"
+import useOrderElement from "@/hooks/useOrderElement"
+import useAlignElementToCanvas from "@/hooks/useAlignElementToCanvas"
+import useCopyAndPasteElement from "@/hooks/useCopyAndPasteElement"
+import useSelectElement from "@/hooks/useSelectElement"
 
-import { ElementOrderCommands, ElementAlignCommands } from '@/types/edit'
+import { ElementOrderCommands, ElementAlignCommands } from "@/types/edit"
 
-import ImageElement from '@/views/components/element/ImageElement/index.vue'
-import TextElement from '@/views/components/element/TextElement/index.vue'
-import ShapeElement from '@/views/components/element/ShapeElement/index.vue'
-import LineElement from '@/views/components/element/LineElement/index.vue'
-import ChartElement from '@/views/components/element/ChartElement/index.vue'
-import TableElement from '@/views/components/element/TableElement/index.vue'
-import LatexElement from '@/views/components/element/LatexElement/index.vue'
-import VideoElement from '@/views/components/element/VideoElement/index.vue'
-import AudioElement from '@/views/components/element/AudioElement/index.vue'
+import ImageElement from "@/views/components/element/ImageElement/index.vue"
+import TextElement from "@/views/components/element/TextElement/index.vue"
+import ShapeElement from "@/views/components/element/ShapeElement/index.vue"
+import LineElement from "@/views/components/element/LineElement/index.vue"
+import ChartElement from "@/views/components/element/ChartElement/index.vue"
+import TableElement from "@/views/components/element/TableElement/index.vue"
+import LatexElement from "@/views/components/element/LatexElement/index.vue"
+import VideoElement from "@/views/components/element/VideoElement/index.vue"
+import AudioElement from "@/views/components/element/AudioElement/index.vue"
+import cloudCoachElement from "@/views/components/element/cloudCoachElement"
 
 const props = defineProps<{
   elementInfo: PPTElement
@@ -60,6 +56,7 @@ const currentElementComponent = computed<unknown>(() => {
     [ElementTypes.LATEX]: LatexElement,
     [ElementTypes.VIDEO]: VideoElement,
     [ElementTypes.AUDIO]: AudioElement,
+    [ElementTypes.CLOUDCOACH]: cloudCoachElement
   }
   return elementTypeMap[props.elementInfo.type] || null
 })
@@ -74,94 +71,96 @@ const { selectAllElements } = useSelectElement()
 
 const contextmenus = (): ContextmenuItem[] => {
   if (props.elementInfo.lock) {
-    return [{
-      text: '解锁', 
-      handler: () => unlockElement(props.elementInfo),
-    }]
+    return [
+      {
+        text: "解锁",
+        handler: () => unlockElement(props.elementInfo)
+      }
+    ]
   }
 
   return [
     {
-      text: '剪切',
-      subText: 'Ctrl + X',
-      handler: cutElement,
+      text: "剪切",
+      subText: "Ctrl + X",
+      handler: cutElement
     },
     {
-      text: '复制',
-      subText: 'Ctrl + C',
-      handler: copyElement,
+      text: "复制",
+      subText: "Ctrl + C",
+      handler: copyElement
     },
     {
-      text: '粘贴',
-      subText: 'Ctrl + V',
-      handler: pasteElement,
+      text: "粘贴",
+      subText: "Ctrl + V",
+      handler: pasteElement
     },
     { divider: true },
     {
-      text: '水平居中',
+      text: "水平居中",
       handler: () => alignElementToCanvas(ElementAlignCommands.HORIZONTAL),
       children: [
-        { text: '水平垂直居中', handler: () => alignElementToCanvas(ElementAlignCommands.CENTER), },
-        { text: '水平居中', handler: () => alignElementToCanvas(ElementAlignCommands.HORIZONTAL) },
-        { text: '左对齐', handler: () => alignElementToCanvas(ElementAlignCommands.LEFT) },
-        { text: '右对齐', handler: () => alignElementToCanvas(ElementAlignCommands.RIGHT) },
-      ],
+        { text: "水平垂直居中", handler: () => alignElementToCanvas(ElementAlignCommands.CENTER) },
+        { text: "水平居中", handler: () => alignElementToCanvas(ElementAlignCommands.HORIZONTAL) },
+        { text: "左对齐", handler: () => alignElementToCanvas(ElementAlignCommands.LEFT) },
+        { text: "右对齐", handler: () => alignElementToCanvas(ElementAlignCommands.RIGHT) }
+      ]
     },
     {
-      text: '垂直居中',
+      text: "垂直居中",
       handler: () => alignElementToCanvas(ElementAlignCommands.VERTICAL),
       children: [
-        { text: '水平垂直居中', handler: () => alignElementToCanvas(ElementAlignCommands.CENTER) },
-        { text: '垂直居中', handler: () => alignElementToCanvas(ElementAlignCommands.VERTICAL) },
-        { text: '顶部对齐', handler: () => alignElementToCanvas(ElementAlignCommands.TOP) },
-        { text: '底部对齐', handler: () => alignElementToCanvas(ElementAlignCommands.BOTTOM) },
-      ],
+        { text: "水平垂直居中", handler: () => alignElementToCanvas(ElementAlignCommands.CENTER) },
+        { text: "垂直居中", handler: () => alignElementToCanvas(ElementAlignCommands.VERTICAL) },
+        { text: "顶部对齐", handler: () => alignElementToCanvas(ElementAlignCommands.TOP) },
+        { text: "底部对齐", handler: () => alignElementToCanvas(ElementAlignCommands.BOTTOM) }
+      ]
     },
     { divider: true },
     {
-      text: '置于顶层',
+      text: "置于顶层",
       disable: props.isMultiSelect && !props.elementInfo.groupId,
       handler: () => orderElement(props.elementInfo, ElementOrderCommands.TOP),
       children: [
-        { text: '置于顶层', handler: () => orderElement(props.elementInfo, ElementOrderCommands.TOP) },
-        { text: '上移一层', handler: () => orderElement(props.elementInfo, ElementOrderCommands.UP) },
-      ],
+        { text: "置于顶层", handler: () => orderElement(props.elementInfo, ElementOrderCommands.TOP) },
+        { text: "上移一层", handler: () => orderElement(props.elementInfo, ElementOrderCommands.UP) }
+      ]
     },
     {
-      text: '置于底层',
+      text: "置于底层",
       disable: props.isMultiSelect && !props.elementInfo.groupId,
       handler: () => orderElement(props.elementInfo, ElementOrderCommands.BOTTOM),
       children: [
-        { text: '置于底层', handler: () => orderElement(props.elementInfo, ElementOrderCommands.BOTTOM) },
-        { text: '下移一层', handler: () => orderElement(props.elementInfo, ElementOrderCommands.DOWN) },
-      ],
+        { text: "置于底层", handler: () => orderElement(props.elementInfo, ElementOrderCommands.BOTTOM) },
+        { text: "下移一层", handler: () => orderElement(props.elementInfo, ElementOrderCommands.DOWN) }
+      ]
     },
     { divider: true },
     {
-      text: '设置链接',
-      handler: props.openLinkDialog,
+      text: "设置链接",
+      handler: props.openLinkDialog
     },
     {
-      text: props.elementInfo.groupId ? '取消组合' : '组合',
-      subText: 'Ctrl + G',
+      text: props.elementInfo.groupId ? "取消组合" : "组合",
+      subText: "Ctrl + G",
       handler: props.elementInfo.groupId ? uncombineElements : combineElements,
-      hide: !props.isMultiSelect,
+      hide: !props.isMultiSelect
     },
     {
-      text: '全选',
-      subText: 'Ctrl + A',
-      handler: selectAllElements,
+      text: "全选",
+      subText: "Ctrl + A",
+      handler: selectAllElements
     },
     {
-      text: '锁定',
-      subText: 'Ctrl + L',
-      handler: lockElement,
+      text: "锁定",
+      subText: "Ctrl + L",
+      handler: lockElement
     },
     {
-      text: '删除',
-      subText: 'Delete',
-      handler: deleteElement,
-    },
+      text: "删除",
+      subText: "Delete",
+      handler: deleteElement
+    }
   ]
 }
-</script>
+</script>

+ 24 - 26
src/views/Editor/Canvas/Operate/index.vue

@@ -6,7 +6,7 @@
       top: elementInfo.top * canvasScale + 'px',
       left: elementInfo.left * canvasScale + 'px',
       transform: `rotate(${rotate}deg)`,
-      transformOrigin: `${elementInfo.width * canvasScale / 2}px ${height * canvasScale / 2}px`,
+      transformOrigin: `${(elementInfo.width * canvasScale) / 2}px ${(height * canvasScale) / 2}px`
     }"
   >
     <component
@@ -20,27 +20,24 @@
       :moveShapeKeypoint="moveShapeKeypoint"
     ></component>
 
-    <div 
-      class="animation-index"
-      v-if="toolbarState === 'elAnimation' && elementIndexListInAnimation.length"
-    >
-      <div class="index-item" v-for="index in elementIndexListInAnimation" :key="index">{{index + 1}}</div>
+    <div class="animation-index" v-if="toolbarState === 'elAnimation' && elementIndexListInAnimation.length">
+      <div class="index-item" v-for="index in elementIndexListInAnimation" :key="index">{{ index + 1 }}</div>
     </div>
 
-    <LinkHandler 
-      :elementInfo="elementInfo" 
+    <LinkHandler
+      :elementInfo="elementInfo"
       :link="elementInfo.link"
-      :openLinkDialog="openLinkDialog" 
-      v-if="isActive && elementInfo.link" 
+      :openLinkDialog="openLinkDialog"
+      v-if="isActive && elementInfo.link"
       @mousedown.stop=""
     />
   </div>
 </template>
 
 <script lang="ts" setup>
-import { computed } from 'vue'
-import { storeToRefs } from 'pinia'
-import { useMainStore, useSlidesStore } from '@/store'
+import { computed } from "vue"
+import { storeToRefs } from "pinia"
+import { useMainStore, useSlidesStore } from "@/store"
 import {
   ElementTypes,
   type PPTElement,
@@ -48,17 +45,17 @@ import {
   type PPTVideoElement,
   type PPTAudioElement,
   type PPTShapeElement,
-  type PPTChartElement,
-} from '@/types/slides'
-import type { OperateLineHandlers, OperateResizeHandlers } from '@/types/edit'
+  type PPTChartElement
+} from "@/types/slides"
+import type { OperateLineHandlers, OperateResizeHandlers } from "@/types/edit"
 
-import ImageElementOperate from './ImageElementOperate.vue'
-import TextElementOperate from './TextElementOperate.vue'
-import ShapeElementOperate from './ShapeElementOperate.vue'
-import LineElementOperate from './LineElementOperate.vue'
-import TableElementOperate from './TableElementOperate.vue'
-import CommonElementOperate from './CommonElementOperate.vue'
-import LinkHandler from './LinkHandler.vue'
+import ImageElementOperate from "./ImageElementOperate.vue"
+import TextElementOperate from "./TextElementOperate.vue"
+import ShapeElementOperate from "./ShapeElementOperate.vue"
+import LineElementOperate from "./LineElementOperate.vue"
+import TableElementOperate from "./TableElementOperate.vue"
+import CommonElementOperate from "./CommonElementOperate.vue"
+import LinkHandler from "./LinkHandler.vue"
 
 const props = defineProps<{
   elementInfo: PPTElement
@@ -87,6 +84,7 @@ const currentOperateComponent = computed<unknown>(() => {
     [ElementTypes.LATEX]: CommonElementOperate,
     [ElementTypes.VIDEO]: CommonElementOperate,
     [ElementTypes.AUDIO]: CommonElementOperate,
+    [ElementTypes.CLOUDCOACH]: CommonElementOperate
   }
   return elementTypeMap[props.elementInfo.type] || null
 })
@@ -100,8 +98,8 @@ const elementIndexListInAnimation = computed(() => {
   return indexList
 })
 
-const rotate = computed(() => 'rotate' in props.elementInfo ? props.elementInfo.rotate : 0)
-const height = computed(() => 'height' in props.elementInfo ? props.elementInfo.height : 0)
+const rotate = computed(() => ("rotate" in props.elementInfo ? props.elementInfo.rotate : 0))
+const height = computed(() => ("height" in props.elementInfo ? props.elementInfo.height : 0))
 </script>
 
 <style lang="scss" scoped>
@@ -135,4 +133,4 @@ const height = computed(() => 'height' in props.elementInfo ? props.elementInfo.
     }
   }
 }
-</style>
+</style>

+ 105 - 125
src/views/Editor/Canvas/index.vue

@@ -1,6 +1,6 @@
 <template>
-  <div 
-    class="canvas" 
+  <div
+    class="canvas"
     ref="canvasRef"
     @wheel="$event => handleMousewheelCanvas($event)"
     @mousedown="$event => handleClickBlankArea($event)"
@@ -8,39 +8,29 @@
     v-contextmenu="contextmenus"
     v-click-outside="removeEditorAreaFocus"
   >
-    <ElementCreateSelection
-      v-if="creatingElement"
-      @created="data => insertElementFromCreateSelection(data)"
-    />
-    <ShapeCreateCanvas
-      v-if="creatingCustomShape"
-      @created="data => insertCustomShape(data)"
-    />
-    <div 
+    <ElementCreateSelection v-if="creatingElement" @created="data => insertElementFromCreateSelection(data)" />
+    <ShapeCreateCanvas v-if="creatingCustomShape" @created="data => insertCustomShape(data)" />
+    <div
       class="viewport-wrapper"
       :style="{
         width: viewportStyles.width * canvasScale + 'px',
         height: viewportStyles.height * canvasScale + 'px',
         left: viewportStyles.left + 'px',
-        top: viewportStyles.top + 'px',
+        top: viewportStyles.top + 'px'
       }"
     >
       <div class="operates">
-        <AlignmentLine 
-          v-for="(line, index) in alignmentLines" 
-          :key="index" 
-          :type="line.type" 
-          :axis="line.axis" 
+        <AlignmentLine
+          v-for="(line, index) in alignmentLines"
+          :key="index"
+          :type="line.type"
+          :axis="line.axis"
           :length="line.length"
           :canvasScale="canvasScale"
         />
-        <MultiSelectOperate 
-          v-if="activeElementIdList.length > 1"
-          :elementList="elementList"
-          :scaleMultiElement="scaleMultiElement"
-        />
+        <MultiSelectOperate v-if="activeElementIdList.length > 1" :elementList="elementList" :scaleMultiElement="scaleMultiElement" />
         <Operate
-          v-for="element in elementList" 
+          v-for="element in elementList"
           :key="element.id"
           :elementInfo="element"
           :isSelected="activeElementIdList.includes(element.id)"
@@ -57,21 +47,17 @@
         <ViewportBackground />
       </div>
 
-      <div 
-        class="viewport" 
-        ref="viewportRef"
-        :style="{ transform: `scale(${canvasScale})` }"
-      >
-        <MouseSelection 
+      <div class="viewport" ref="viewportRef" :style="{ transform: `scale(${canvasScale})` }">
+        <MouseSelection
           v-if="mouseSelectionVisible"
-          :top="mouseSelection.top" 
-          :left="mouseSelection.left" 
-          :width="mouseSelection.width" 
-          :height="mouseSelection.height" 
+          :top="mouseSelection.top"
+          :left="mouseSelection.left"
+          :width="mouseSelection.width"
+          :height="mouseSelection.height"
           :quadrant="mouseSelectionQuadrant"
-        />      
-        <EditableElement 
-          v-for="(element, index) in elementList" 
+        />
+        <EditableElement
+          v-for="(element, index) in elementList"
           :key="element.id"
           :elementInfo="element"
           :elementIndex="index + 1"
@@ -87,57 +73,54 @@
 
     <Ruler :viewportStyles="viewportStyles" :elementList="elementList" v-if="showRuler" />
 
-    <Modal
-      v-model:visible="linkDialogVisible" 
-      :width="540"
-    >
+    <Modal v-model:visible="linkDialogVisible" :width="540">
       <LinkDialog @close="linkDialogVisible = false" />
     </Modal>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onMounted, onUnmounted, provide, ref, watch, watchEffect } from 'vue'
-import { throttle } from 'lodash'
-import { storeToRefs } from 'pinia'
-import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store'
-import type { ContextmenuItem } from '@/components/Contextmenu/types'
-import type { PPTElement, PPTShapeElement } from '@/types/slides'
-import type { AlignmentLineProps, CreateCustomShapeData } from '@/types/edit'
-import { injectKeySlideScale } from '@/types/injectKey'
-import { removeAllRanges } from '@/utils/selection'
-import { KEYS } from '@/configs/hotkey'
-
-import useViewportSize from './hooks/useViewportSize'
-import useMouseSelection from './hooks/useMouseSelection'
-import useDropImageOrText from './hooks/useDropImageOrText'
-import useRotateElement from './hooks/useRotateElement'
-import useScaleElement from './hooks/useScaleElement'
-import useSelectAndMoveElement from './hooks/useSelectElement'
-import useDragElement from './hooks/useDragElement'
-import useDragLineElement from './hooks/useDragLineElement'
-import useMoveShapeKeypoint from './hooks/useMoveShapeKeypoint'
-import useInsertFromCreateSelection from './hooks/useInsertFromCreateSelection'
-
-import useDeleteElement from '@/hooks/useDeleteElement'
-import useCopyAndPasteElement from '@/hooks/useCopyAndPasteElement'
-import useSelectElement from '@/hooks/useSelectElement'
-import useScaleCanvas from '@/hooks/useScaleCanvas'
-import useScreening from '@/hooks/useScreening'
-import useSlideHandler from '@/hooks/useSlideHandler'
-import useCreateElement from '@/hooks/useCreateElement'
-
-import EditableElement from './EditableElement.vue'
-import MouseSelection from './MouseSelection.vue'
-import ViewportBackground from './ViewportBackground.vue'
-import AlignmentLine from './AlignmentLine.vue'
-import Ruler from './Ruler.vue'
-import ElementCreateSelection from './ElementCreateSelection.vue'
-import ShapeCreateCanvas from './ShapeCreateCanvas.vue'
-import MultiSelectOperate from './Operate/MultiSelectOperate.vue'
-import Operate from './Operate/index.vue'
-import LinkDialog from './LinkDialog.vue'
-import Modal from '@/components/Modal.vue'
+import { nextTick, onMounted, onUnmounted, provide, ref, watch, watchEffect } from "vue"
+import { throttle } from "lodash"
+import { storeToRefs } from "pinia"
+import { useMainStore, useSlidesStore, useKeyboardStore } from "@/store"
+import type { ContextmenuItem } from "@/components/Contextmenu/types"
+import type { PPTElement, PPTShapeElement } from "@/types/slides"
+import type { AlignmentLineProps, CreateCustomShapeData } from "@/types/edit"
+import { injectKeySlideScale } from "@/types/injectKey"
+import { removeAllRanges } from "@/utils/selection"
+import { KEYS } from "@/configs/hotkey"
+
+import useViewportSize from "./hooks/useViewportSize"
+import useMouseSelection from "./hooks/useMouseSelection"
+import useDropImageOrText from "./hooks/useDropImageOrText"
+import useRotateElement from "./hooks/useRotateElement"
+import useScaleElement from "./hooks/useScaleElement"
+import useSelectAndMoveElement from "./hooks/useSelectElement"
+import useDragElement from "./hooks/useDragElement"
+import useDragLineElement from "./hooks/useDragLineElement"
+import useMoveShapeKeypoint from "./hooks/useMoveShapeKeypoint"
+import useInsertFromCreateSelection from "./hooks/useInsertFromCreateSelection"
+
+import useDeleteElement from "@/hooks/useDeleteElement"
+import useCopyAndPasteElement from "@/hooks/useCopyAndPasteElement"
+import useSelectElement from "@/hooks/useSelectElement"
+import useScaleCanvas from "@/hooks/useScaleCanvas"
+import useScreening from "@/hooks/useScreening"
+import useSlideHandler from "@/hooks/useSlideHandler"
+import useCreateElement from "@/hooks/useCreateElement"
+
+import EditableElement from "./EditableElement.vue"
+import MouseSelection from "./MouseSelection.vue"
+import ViewportBackground from "./ViewportBackground.vue"
+import AlignmentLine from "./AlignmentLine.vue"
+import Ruler from "./Ruler.vue"
+import ElementCreateSelection from "./ElementCreateSelection.vue"
+import ShapeCreateCanvas from "./ShapeCreateCanvas.vue"
+import MultiSelectOperate from "./Operate/MultiSelectOperate.vue"
+import Operate from "./Operate/index.vue"
+import LinkDialog from "./LinkDialog.vue"
+import Modal from "@/components/Modal.vue"
 
 const mainStore = useMainStore()
 const {
@@ -151,7 +134,7 @@ const {
   creatingElement,
   creatingCustomShape,
   canvasScale,
-  textFormatPainter,
+  textFormatPainter
 } = storeToRefs(mainStore)
 const { currentSlide } = storeToRefs(useSlidesStore())
 const { ctrlKeyState, spaceKeyState } = storeToRefs(useKeyboardStore())
@@ -160,10 +143,10 @@ const viewportRef = ref<HTMLElement>()
 const alignmentLines = ref<AlignmentLineProps[]>([])
 
 const linkDialogVisible = ref(false)
-const openLinkDialog = () => linkDialogVisible.value = true
+const openLinkDialog = () => (linkDialogVisible.value = true)
 
 watch(handleElementId, () => {
-  mainStore.setActiveGroupElementId('')
+  mainStore.setActiveGroupElementId("")
 })
 
 const elementList = ref<PPTElement[]>([])
@@ -226,7 +209,7 @@ const handleDblClick = (e: MouseEvent) => {
     left,
     top,
     width: 200 / canvasScale.value, // 除以 canvasScale 是为了与点击选区创建的形式保持相同的宽度
-    height: 0,
+    height: 0
   })
 }
 
@@ -250,8 +233,8 @@ const handleMousewheelCanvas = (e: WheelEvent) => {
 
   // 按住Ctrl键时:缩放画布
   if (ctrlKeyState.value) {
-    if (e.deltaY > 0) throttleScaleCanvas('-')
-    else if (e.deltaY < 0) throttleScaleCanvas('+')
+    if (e.deltaY > 0) throttleScaleCanvas("-")
+    else if (e.deltaY < 0) throttleScaleCanvas("+")
   }
   // 上下翻页
   else {
@@ -270,12 +253,7 @@ const { insertElementFromCreateSelection, formatCreateSelection } = useInsertFro
 
 // 插入自定义任意多边形
 const insertCustomShape = (data: CreateCustomShapeData) => {
-  const {
-    start,
-    end,
-    path,
-    viewBox,
-  } = data
+  const { start, end, path, viewBox } = data
   const position = formatCreateSelection({ start, end })
   if (position) {
     const supplement: Partial<PPTShapeElement> = {}
@@ -290,56 +268,56 @@ const insertCustomShape = (data: CreateCustomShapeData) => {
 const contextmenus = (): ContextmenuItem[] => {
   return [
     {
-      text: '粘贴',
-      subText: 'Ctrl + V',
-      handler: pasteElement,
+      text: "粘贴",
+      subText: "Ctrl + V",
+      handler: pasteElement
     },
     {
-      text: '全选',
-      subText: 'Ctrl + A',
-      handler: selectAllElements,
+      text: "全选",
+      subText: "Ctrl + A",
+      handler: selectAllElements
     },
     {
-      text: '标尺',
-      subText: showRuler.value ? '√' : '',
-      handler: toggleRuler,
+      text: "标尺",
+      subText: showRuler.value ? "√" : "",
+      handler: toggleRuler
     },
     {
-      text: '网格线',
+      text: "网格线",
       handler: () => mainStore.setGridLineSize(gridLineSize.value ? 0 : 50),
       children: [
         {
-          text: '无',
-          subText: gridLineSize.value === 0 ? '√' : '',
-          handler: () => mainStore.setGridLineSize(0),
+          text: "无",
+          subText: gridLineSize.value === 0 ? "√" : "",
+          handler: () => mainStore.setGridLineSize(0)
         },
         {
-          text: '小',
-          subText: gridLineSize.value === 25 ? '√' : '',
-          handler: () => mainStore.setGridLineSize(25),
+          text: "小",
+          subText: gridLineSize.value === 25 ? "√" : "",
+          handler: () => mainStore.setGridLineSize(25)
         },
         {
-          text: '中',
-          subText: gridLineSize.value === 50 ? '√' : '',
-          handler: () => mainStore.setGridLineSize(50),
+          text: "中",
+          subText: gridLineSize.value === 50 ? "√" : "",
+          handler: () => mainStore.setGridLineSize(50)
         },
         {
-          text: '大',
-          subText: gridLineSize.value === 100 ? '√' : '',
-          handler: () => mainStore.setGridLineSize(100),
-        },
-      ],
+          text: "大",
+          subText: gridLineSize.value === 100 ? "√" : "",
+          handler: () => mainStore.setGridLineSize(100)
+        }
+      ]
     },
     {
-      text: '重置当前页',
-      handler: deleteAllElements,
+      text: "重置当前页",
+      handler: deleteAllElements
     },
     { divider: true },
     {
-      text: '幻灯片放映',
-      subText: 'F5',
-      handler: enterScreeningFromStart,
-    },
+      text: "幻灯片放映",
+      subText: "F5",
+      handler: enterScreeningFromStart
+    }
   ]
 }
 
@@ -360,7 +338,9 @@ provide(injectKeySlideScale, canvasScale)
 }
 .viewport-wrapper {
   position: absolute;
-  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 0 0 12px 0 rgba(0, 0, 0, 0.1);
+  box-shadow:
+    0 0 0 1px rgba(0, 0, 0, 0.01),
+    0 0 12px 0 rgba(0, 0, 0, 0.1);
 }
 .viewport {
   position: absolute;
@@ -368,4 +348,4 @@ provide(injectKeySlideScale, canvasScale)
   left: 0;
   transform-origin: 0 0;
 }
-</style>
+</style>

+ 167 - 77
src/views/Editor/CanvasTool/index.vue

@@ -1,47 +1,101 @@
 <template>
   <div class="canvas-tool">
     <div class="left-handler">
-      <IconBack class="handler-item" :class="{ 'disable': !canUndo }" v-tooltip="'撤销(Ctrl + Z)'" @click="undo()" />
-      <IconNext class="handler-item" :class="{ 'disable': !canRedo }" v-tooltip="'重做(Ctrl + Y)'" @click="redo()" />
+      <IconBack class="handler-item" :class="{ disable: !canUndo }" v-tooltip="'撤销(Ctrl + Z)'" @click="undo()" />
+      <IconNext class="handler-item" :class="{ disable: !canRedo }" v-tooltip="'重做(Ctrl + Y)'" @click="redo()" />
       <div class="more">
-        <Divider type="vertical" style="height: 20px;" />
+        <Divider type="vertical" style="height: 20px" />
         <Popover class="more-icon" trigger="click" v-model:value="moreVisible" :offset="10">
           <template #content>
-            <PopoverMenuItem center @click="toggleNotesPanel(); moreVisible = false">批注面板</PopoverMenuItem>
-            <PopoverMenuItem center @click="toggleSelectPanel(); moreVisible = false">选择窗格</PopoverMenuItem>
-            <PopoverMenuItem center @click="toggleSraechPanel(); moreVisible = false">查找替换</PopoverMenuItem>
+            <PopoverMenuItem
+              center
+              @click="
+                () => {
+                  toggleNotesPanel()
+                  moreVisible = false
+                }
+              "
+              >批注面板</PopoverMenuItem
+            >
+            <PopoverMenuItem
+              center
+              @click="
+                () => {
+                  toggleSelectPanel()
+                  moreVisible = false
+                }
+              "
+              >选择窗格</PopoverMenuItem
+            >
+            <PopoverMenuItem
+              center
+              @click="
+                () => {
+                  toggleSraechPanel()
+                  moreVisible = false
+                }
+              "
+              >查找替换</PopoverMenuItem
+            >
           </template>
           <IconMore class="handler-item" />
         </Popover>
-        <IconComment class="handler-item" :class="{ 'active': showNotesPanel }" v-tooltip="'批注面板'" @click="toggleNotesPanel()" />
-        <IconMoveOne class="handler-item" :class="{ 'active': showSelectPanel }" v-tooltip="'选择窗格'" @click="toggleSelectPanel()" />
-        <IconSearch class="handler-item" :class="{ 'active': showSearchPanel }" v-tooltip="'查找/替换(Ctrl + F)'" @click="toggleSraechPanel()" />
+        <IconComment class="handler-item" :class="{ active: showNotesPanel }" v-tooltip="'批注面板'" @click="toggleNotesPanel()" />
+        <IconMoveOne class="handler-item" :class="{ active: showSelectPanel }" v-tooltip="'选择窗格'" @click="toggleSelectPanel()" />
+        <IconSearch class="handler-item" :class="{ active: showSearchPanel }" v-tooltip="'查找/替换(Ctrl + F)'" @click="toggleSraechPanel()" />
       </div>
     </div>
 
     <div class="add-element-handler">
       <div class="handler-item group-btn" v-tooltip="'插入文字'">
-        <IconFontSize class="icon" :class="{ 'active': creatingElement?.type === 'text' }" @click="drawText()" />
-        
-        <Popover trigger="click" v-model:value="textTypeSelectVisible" style="height: 100%;" :offset="10">
+        <IconFontSize class="icon" :class="{ active: creatingElement?.type === 'text' }" @click="drawText()" />
+
+        <Popover trigger="click" v-model:value="textTypeSelectVisible" style="height: 100%" :offset="10">
           <template #content>
-            <PopoverMenuItem center @click="() => { drawText(); textTypeSelectVisible = false }"><IconTextRotationNone /> 横向文本框</PopoverMenuItem>
-            <PopoverMenuItem center @click="() => { drawText(true); textTypeSelectVisible = false }"><IconTextRotationDown /> 竖向文本框</PopoverMenuItem>
+            <PopoverMenuItem
+              center
+              @click="
+                () => {
+                  drawText()
+                  textTypeSelectVisible = false
+                }
+              "
+              ><IconTextRotationNone /> 横向文本框</PopoverMenuItem
+            >
+            <PopoverMenuItem
+              center
+              @click="
+                () => {
+                  drawText(true)
+                  textTypeSelectVisible = false
+                }
+              "
+              ><IconTextRotationDown /> 竖向文本框</PopoverMenuItem
+            >
           </template>
           <IconDown class="arrow" />
         </Popover>
       </div>
       <div class="handler-item group-btn" v-tooltip="'插入形状'" :offset="10">
-        <Popover trigger="click" style="height: 100%;" v-model:value="shapePoolVisible" :offset="10">
+        <Popover trigger="click" style="height: 100%" v-model:value="shapePoolVisible" :offset="10">
           <template #content>
             <ShapePool @select="shape => drawShape(shape)" />
           </template>
-          <IconGraphicDesign class="icon" :class="{ 'active': creatingCustomShape || creatingElement?.type === 'shape' }" />
+          <IconGraphicDesign class="icon" :class="{ active: creatingCustomShape || creatingElement?.type === 'shape' }" />
         </Popover>
-        
-        <Popover trigger="click" v-model:value="shapeMenuVisible" style="height: 100%;" :offset="10">
+
+        <Popover trigger="click" v-model:value="shapeMenuVisible" style="height: 100%" :offset="10">
           <template #content>
-            <PopoverMenuItem center @click="() => { drawCustomShape(); shapeMenuVisible = false }">自由绘制</PopoverMenuItem>
+            <PopoverMenuItem
+              center
+              @click="
+                () => {
+                  drawCustomShape()
+                  shapeMenuVisible = false
+                }
+              "
+              >自由绘制</PopoverMenuItem
+            >
           </template>
           <IconDown class="arrow" />
         </Popover>
@@ -53,11 +107,18 @@
         <template #content>
           <LinePool @select="line => drawLine(line)" />
         </template>
-        <IconConnection class="handler-item" :class="{ 'active': creatingElement?.type === 'line' }" v-tooltip="'插入线条'" />
+        <IconConnection class="handler-item" :class="{ active: creatingElement?.type === 'line' }" v-tooltip="'插入线条'" />
       </Popover>
       <Popover trigger="click" v-model:value="chartPoolVisible" :offset="10">
         <template #content>
-          <ChartPool @select="chart => { createChartElement(chart); chartPoolVisible = false }" />
+          <ChartPool
+            @select="
+              chart => {
+                createChartElement(chart)
+                chartPoolVisible = false
+              }
+            "
+          />
         </template>
         <IconChartProportion class="handler-item" v-tooltip="'插入图表'" />
       </Popover>
@@ -65,7 +126,12 @@
         <template #content>
           <TableGenerator
             @close="tableGeneratorVisible = false"
-            @insert="({ row, col }) => { createTableElement(row, col); tableGeneratorVisible = false }"
+            @insert="
+              ({ row, col }) => {
+                createTableElement(row, col)
+                tableGeneratorVisible = false
+              }
+            "
           />
         </template>
         <IconInsertTable class="handler-item" v-tooltip="'插入表格'" />
@@ -73,68 +139,84 @@
       <IconFormula class="handler-item" v-tooltip="'插入公式'" @click="latexEditorVisible = true" />
       <Popover trigger="click" v-model:value="mediaInputVisible" :offset="10">
         <template #content>
-          <MediaInput 
+          <MediaInput
             @close="mediaInputVisible = false"
-            @insertVideo="src => { createVideoElement(src); mediaInputVisible = false }"
-            @insertAudio="src => { createAudioElement(src); mediaInputVisible = false }"
+            @insertVideo="
+              src => {
+                createVideoElement(src)
+                mediaInputVisible = false
+              }
+            "
+            @insertAudio="
+              src => {
+                createAudioElement(src)
+                mediaInputVisible = false
+              }
+            "
           />
         </template>
         <IconVideoTwo class="handler-item" v-tooltip="'插入音视频'" />
       </Popover>
+      <IconLinkCloud class="handler-item" v-tooltip="'插入云教练'" @click="cloudCoachVisible = true" />
     </div>
 
     <div class="right-handler">
       <IconMinus class="handler-item viewport-size" v-tooltip="'画布缩小(Ctrl + -)'" @click="scaleCanvas('-')" />
       <Popover trigger="click" v-model:value="canvasScaleVisible">
         <template #content>
-          <PopoverMenuItem
-            center
-            v-for="item in canvasScalePresetList" 
-            :key="item" 
-            @click="applyCanvasPresetScale(item)"
-          >{{item}}%</PopoverMenuItem>
+          <PopoverMenuItem center v-for="item in canvasScalePresetList" :key="item" @click="applyCanvasPresetScale(item)"
+            >{{ item }}%</PopoverMenuItem
+          >
           <PopoverMenuItem center @click="resetCanvas()">适应屏幕</PopoverMenuItem>
         </template>
-        <span class="text">{{canvasScalePercentage}}</span>
+        <span class="text">{{ canvasScalePercentage }}</span>
       </Popover>
       <IconPlus class="handler-item viewport-size" v-tooltip="'画布放大(Ctrl + =)'" @click="scaleCanvas('+')" />
       <IconFullScreen class="handler-item viewport-size-adaptation" v-tooltip="'适应屏幕(Ctrl + 0)'" @click="resetCanvas()" />
     </div>
 
-    <Modal
-      v-model:visible="latexEditorVisible" 
-      :width="880"
-    >
-      <LaTeXEditor 
+    <Modal v-model:visible="latexEditorVisible" :width="880">
+      <LaTeXEditor
         @close="latexEditorVisible = false"
-        @update="data => { createLatexElement(data); latexEditorVisible = false }"
+        @update="
+          data => {
+            createLatexElement(data)
+            latexEditorVisible = false
+          }
+        "
       />
     </Modal>
+    <Modal v-model:visible="cloudCoachVisible" :width="600">
+      <cloudCoachList @update="handleCloudCoach" />
+    </Modal>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { ref } from 'vue'
-import { storeToRefs } from 'pinia'
-import { useMainStore, useSnapshotStore } from '@/store'
-import { getImageDataURL } from '@/utils/image'
-import type { ShapePoolItem } from '@/configs/shapes'
-import type { LinePoolItem } from '@/configs/lines'
-import useScaleCanvas from '@/hooks/useScaleCanvas'
-import useHistorySnapshot from '@/hooks/useHistorySnapshot'
-import useCreateElement from '@/hooks/useCreateElement'
-
-import ShapePool from './ShapePool.vue'
-import LinePool from './LinePool.vue'
-import ChartPool from './ChartPool.vue'
-import TableGenerator from './TableGenerator.vue'
-import MediaInput from './MediaInput.vue'
-import LaTeXEditor from '@/components/LaTeXEditor/index.vue'
-import FileInput from '@/components/FileInput.vue'
-import Modal from '@/components/Modal.vue'
-import Divider from '@/components/Divider.vue'
-import Popover from '@/components/Popover.vue'
-import PopoverMenuItem from '@/components/PopoverMenuItem.vue'
+import { ref } from "vue"
+import { storeToRefs } from "pinia"
+import { useMainStore, useSnapshotStore } from "@/store"
+import { getImageDataURL } from "@/utils/image"
+import type { ShapePoolItem } from "@/configs/shapes"
+import type { LinePoolItem } from "@/configs/lines"
+import useScaleCanvas from "@/hooks/useScaleCanvas"
+import useHistorySnapshot from "@/hooks/useHistorySnapshot"
+import useCreateElement from "@/hooks/useCreateElement"
+
+import ShapePool from "./ShapePool.vue"
+import LinePool from "./LinePool.vue"
+import ChartPool from "./ChartPool.vue"
+import TableGenerator from "./TableGenerator.vue"
+import MediaInput from "./MediaInput.vue"
+import LaTeXEditor from "@/components/LaTeXEditor/index.vue"
+import FileInput from "@/components/FileInput.vue"
+import Modal from "@/components/Modal.vue"
+import Divider from "@/components/Divider.vue"
+import Popover from "@/components/Popover.vue"
+import PopoverMenuItem from "@/components/PopoverMenuItem.vue"
+import cloudCoachList from "@/views/components/element/cloudCoachElement/cloudCoachList"
+import { YJL_URL_API } from "@/config/index"
+import { getToken } from "@/libs/auth"
 
 const mainStore = useMainStore()
 const { creatingElement, creatingCustomShape, showSelectPanel, showSearchPanel, showNotesPanel } = storeToRefs(mainStore)
@@ -142,12 +224,7 @@ const { canUndo, canRedo } = storeToRefs(useSnapshotStore())
 
 const { redo, undo } = useHistorySnapshot()
 
-const {
-  scaleCanvas,
-  setCanvasScalePercentage,
-  resetCanvas,
-  canvasScalePercentage,
-} = useScaleCanvas()
+const { scaleCanvas, setCanvasScalePercentage, resetCanvas, canvasScalePercentage } = useScaleCanvas()
 
 const canvasScalePresetList = [200, 150, 125, 100, 75, 50]
 const canvasScaleVisible = ref(false)
@@ -164,6 +241,7 @@ const {
   createLatexElement,
   createVideoElement,
   createAudioElement,
+  createCloudCoachElement
 } = useCreateElement()
 
 const insertImageElement = (files: FileList) => {
@@ -181,20 +259,28 @@ const latexEditorVisible = ref(false)
 const textTypeSelectVisible = ref(false)
 const shapeMenuVisible = ref(false)
 const moreVisible = ref(false)
+const cloudCoachVisible = ref(false)
+
+// 处理云教练创建
+function handleCloudCoach(id: string) {
+  const YJL_URL = `${YJL_URL_API}?v=${Date.now()}&modelType=practise&id=${id}&Authorization=${getToken()}&platform=pc&zoom=0.8&instrumentId=`
+  createCloudCoachElement(YJL_URL)
+  cloudCoachVisible.value = false
+}
 
 // 绘制文字范围
 const drawText = (vertical = false) => {
   mainStore.setCreatingElement({
-    type: 'text',
-    vertical,
+    type: "text",
+    vertical
   })
 }
 
 // 绘制形状范围
 const drawShape = (shape: ShapePoolItem) => {
   mainStore.setCreatingElement({
-    type: 'shape',
-    data: shape,
+    type: "shape",
+    data: shape
   })
   shapePoolVisible.value = false
 }
@@ -207,8 +293,8 @@ const drawCustomShape = () => {
 // 绘制线条路径
 const drawLine = (line: LinePoolItem) => {
   mainStore.setCreatingElement({
-    type: 'line',
-    data: line,
+    type: "line",
+    data: line
   })
   linePoolVisible.value = false
 }
@@ -240,7 +326,8 @@ const toggleNotesPanel = () => {
   font-size: 13px;
   user-select: none;
 }
-.left-handler, .more {
+.left-handler,
+.more {
   display: flex;
   align-items: center;
 }
@@ -273,7 +360,8 @@ const toggleNotesPanel = () => {
         background-color: #f3f3f3;
       }
 
-      .icon, .arrow {
+      .icon,
+      .arrow {
         height: 100%;
         display: flex;
         justify-content: center;
@@ -313,10 +401,11 @@ const toggleNotesPanel = () => {
   cursor: pointer;
 
   &.disable {
-    opacity: .5;
+    opacity: 0.5;
   }
 }
-.left-handler, .right-handler {
+.left-handler,
+.right-handler {
   .handler-item {
     padding: 0 8px;
 
@@ -354,8 +443,9 @@ const toggleNotesPanel = () => {
   }
 }
 @media screen and (width <= 1000px) {
-  .left-handler, .right-handler {
+  .left-handler,
+  .right-handler {
     display: none;
   }
 }
-</style>
+</style>

+ 21 - 21
src/views/Editor/Toolbar/ElementAnimationPanel.vue

@@ -1,18 +1,18 @@
 <template>
   <div class="element-animation-panel">
     <div class="element-animation" v-if="handleElement">
-      <Popover 
-        trigger="click" 
-        v-model:value="animationPoolVisible" 
+      <Popover
+        trigger="click"
+        v-model:value="animationPoolVisible"
         @update:value="visible => handlePopoverVisibleChange(visible)"
         style="width: 100%;"
       >
         <template #content>
-          <Tabs 
-            :tabs="tabs" 
-            v-model:value="activeTab" 
-            :tabsStyle="{ marginBottom: '20px' }" 
-            :tabStyle="{ width: '33.333%' }" 
+          <Tabs
+            :tabs="tabs"
+            v-model:value="activeTab"
+            :tabsStyle="{ marginBottom: '20px' }"
+            :tabStyle="{ width: '33.333%' }"
             spaceAround
           />
           <template v-for="key in animationTypes">
@@ -20,14 +20,14 @@
               <div class="pool-type" :key="effect.name" v-for="effect in animations[key]">
                 <div class="type-title">{{effect.name}}:</div>
                 <div class="pool-item-wrapper">
-                  <div 
-                    class="pool-item" 
+                  <div
+                    class="pool-item"
                     v-for="item in effect.children" :key="item.name"
                     @mouseenter="hoverPreviewAnimation = item.value"
                     @mouseleave="hoverPreviewAnimation = ''"
                     @click="addAnimation(key, item.value)"
                   >
-                    <div 
+                    <div
                       class="animation-box"
                       :class="[
                         `${ANIMATION_CLASS_PREFIX}animated`,
@@ -49,10 +49,10 @@
     </div>
 
     <div class="tip" v-else><IconClick style="margin-right: 5px;" /> 选中画布中的元素添加动画</div>
-    
+
     <Divider />
 
-    <Draggable 
+    <Draggable
       class="animation-sequence"
       :modelValue="animationSequence"
       :animation="200"
@@ -78,13 +78,13 @@
 
             <div class="config-item">
               <div style="width: 35%;">持续时长:</div>
-              <NumberInput 
+              <NumberInput
                 :min="500"
                 :max="3000"
                 :step="500"
-                :value="element.duration" 
-                @update:value="value => updateElementAnimationDuration(element.id, value)" 
-                style="width: 65%;" 
+                :value="element.duration"
+                @update:value="value => updateElementAnimationDuration(element.id, value)"
+                style="width: 65%;"
               />
             </div>
             <div class="config-item">
@@ -123,7 +123,7 @@ import { nanoid } from 'nanoid'
 import { storeToRefs } from 'pinia'
 import { useMainStore, useSlidesStore } from '@/store'
 import type { AnimationTrigger, AnimationType, PPTAnimation } from '@/types/slides'
-import { 
+import {
   ENTER_ANIMATIONS,
   EXIT_ANIMATIONS,
   ATTENTION_ANIMATIONS,
@@ -233,7 +233,7 @@ const handleDragEnd = (eventData: { newIndex: number; oldIndex: number }) => {
   const animation = animations[oldIndex]
   animations.splice(oldIndex, 1)
   animations.splice(newIndex, 0, animation)
-  
+
   slidesStore.updateSlide({ animations })
   addHistorySnapshot()
 }
@@ -260,7 +260,7 @@ const runAllAnimation = async () => {
   for (let i = 0; i < animationSequence.value.length; i++) {
     if (!animateIn.value) break
     const item = animationSequence.value[i]
-    if (item.index !== 1 && item.trigger !== 'meantime') await new Promise(resolve => setTimeout(resolve, item.duration + 100)) 
+    if (item.index !== 1 && item.trigger !== 'meantime') await new Promise(resolve => setTimeout(resolve, item.duration + 100))
     runAnimation(item.elId, item.effect, item.duration)
     if (i >= animationSequence.value.length - 1) animateIn.value = false
   }
@@ -495,4 +495,4 @@ $attentionColor: #e8b76a;
     }
   }
 }
-</style>
+</style>

+ 7 - 0
src/views/Editor/Toolbar/ElementStylePanel/CloudCoachStylePanel.vue

@@ -0,0 +1,7 @@
+<template>
+  <div class="CloudCoachStylePanel">云教练</div>
+</template>
+
+<script setup lang="ts"></script>
+
+<style lang="scss" scoped></style>

+ 20 - 18
src/views/Editor/Toolbar/ElementStylePanel/index.vue

@@ -1,25 +1,26 @@
 <template>
   <div class="element-style-panel">
-    <component :is="currentPanelComponent"></component>
+    <component :is="currentPanelComponent" />
   </div>
 </template>
 
 <script lang="ts" setup>
-import { computed } from 'vue'
-import { storeToRefs } from 'pinia'
-import { useMainStore } from '@/store'
-import { ElementTypes } from '@/types/slides'
+import { computed } from "vue"
+import { storeToRefs } from "pinia"
+import { useMainStore } from "@/store"
+import { ElementTypes } from "@/types/slides"
 
-import TextStylePanel from './TextStylePanel.vue'
-import ImageStylePanel from './ImageStylePanel.vue'
-import ShapeStylePanel from './ShapeStylePanel.vue'
-import LineStylePanel from './LineStylePanel.vue'
-import ChartStylePanel from './ChartStylePanel/index.vue'
-import TableStylePanel from './TableStylePanel.vue'
-import LatexStylePanel from './LatexStylePanel.vue'
-import VideoStylePanel from './VideoStylePanel.vue'
-import AudioStylePanel from './AudioStylePanel.vue'
-import MultiStylePanel from './MultiStylePanel.vue'
+import TextStylePanel from "./TextStylePanel.vue"
+import ImageStylePanel from "./ImageStylePanel.vue"
+import ShapeStylePanel from "./ShapeStylePanel.vue"
+import LineStylePanel from "./LineStylePanel.vue"
+import ChartStylePanel from "./ChartStylePanel/index.vue"
+import TableStylePanel from "./TableStylePanel.vue"
+import LatexStylePanel from "./LatexStylePanel.vue"
+import VideoStylePanel from "./VideoStylePanel.vue"
+import AudioStylePanel from "./AudioStylePanel.vue"
+import MultiStylePanel from "./MultiStylePanel.vue"
+import CloudCoachStylePanel from "./CloudCoachStylePanel.vue"
 
 const panelMap = {
   [ElementTypes.TEXT]: TextStylePanel,
@@ -31,6 +32,7 @@ const panelMap = {
   [ElementTypes.LATEX]: LatexStylePanel,
   [ElementTypes.VIDEO]: VideoStylePanel,
   [ElementTypes.AUDIO]: AudioStylePanel,
+  [ElementTypes.CLOUDCOACH]: CloudCoachStylePanel
 }
 
 const { activeElementIdList, activeElementList, handleElement, activeGroupElementId } = storeToRefs(useMainStore())
@@ -40,9 +42,9 @@ const currentPanelComponent = computed<unknown>(() => {
     if (!activeGroupElementId.value) return MultiStylePanel
 
     const activeGroupElement = activeElementList.value.find(item => item.id === activeGroupElementId.value)
-    return activeGroupElement ? (panelMap[activeGroupElement.type] || null) : null
+    return activeGroupElement ? panelMap[activeGroupElement.type] || null : null
   }
 
-  return handleElement.value ? (panelMap[handleElement.value.type] || null) : null
+  return handleElement.value ? panelMap[handleElement.value.type] || null : null
 })
-</script>
+</script>

+ 35 - 34
src/views/Editor/Toolbar/index.vue

@@ -1,31 +1,26 @@
 <template>
   <div class="toolbar">
-    <Tabs 
-      :tabs="currentTabs" 
-      :value="toolbarState" 
-      card 
-      @update:value="key => setToolbarState(key as ToolbarStates)"
-    />
+    <Tabs :tabs="currentTabs" :value="toolbarState" card @update:value="key => setToolbarState(key as ToolbarStates)" />
     <div class="content">
-      <component :is="currentPanelComponent"></component>
+      <component :is="currentPanelComponent" />
     </div>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { computed, watch } from 'vue'
-import { storeToRefs } from 'pinia'
-import { useMainStore } from '@/store'
-import { ToolbarStates } from '@/types/toolbar'
+import { computed, watch } from "vue"
+import { storeToRefs } from "pinia"
+import { useMainStore } from "@/store"
+import { ToolbarStates } from "@/types/toolbar"
 
-import ElementStylePanel from './ElementStylePanel/index.vue'
-import ElementPositionPanel from './ElementPositionPanel.vue'
-import ElementAnimationPanel from './ElementAnimationPanel.vue'
-import SlideDesignPanel from './SlideDesignPanel.vue'
-import SlideAnimationPanel from './SlideAnimationPanel.vue'
-import MultiPositionPanel from './MultiPositionPanel.vue'
-import SymbolPanel from './SymbolPanel.vue'
-import Tabs from '@/components/Tabs.vue'
+import ElementStylePanel from "./ElementStylePanel/index.vue"
+import ElementPositionPanel from "./ElementPositionPanel.vue"
+import ElementAnimationPanel from "./ElementAnimationPanel.vue"
+import SlideDesignPanel from "./SlideDesignPanel.vue"
+import SlideAnimationPanel from "./SlideAnimationPanel.vue"
+import MultiPositionPanel from "./MultiPositionPanel.vue"
+import SymbolPanel from "./SymbolPanel.vue"
+import Tabs from "@/components/Tabs.vue"
 
 interface ElementTabs {
   label: string
@@ -36,28 +31,34 @@ const mainStore = useMainStore()
 const { activeElementIdList, handleElement, toolbarState } = storeToRefs(mainStore)
 
 const elementTabs = computed<ElementTabs[]>(() => {
-  if (handleElement.value?.type === 'text') {
+  if (handleElement.value?.type === "text") {
     return [
-      { label: '样式', key: ToolbarStates.EL_STYLE },
-      { label: '符号', key: ToolbarStates.SYMBOL },
-      { label: '位置', key: ToolbarStates.EL_POSITION },
-      { label: '动画', key: ToolbarStates.EL_ANIMATION },
+      { label: "样式", key: ToolbarStates.EL_STYLE },
+      { label: "符号", key: ToolbarStates.SYMBOL },
+      { label: "位置", key: ToolbarStates.EL_POSITION },
+      { label: "动画", key: ToolbarStates.EL_ANIMATION }
+    ]
+  }
+  if (handleElement.value?.type === "cloudCoach") {
+    return [
+      { label: "位置", key: ToolbarStates.EL_POSITION },
+      { label: "动画", key: ToolbarStates.EL_ANIMATION }
     ]
   }
   return [
-    { label: '样式', key: ToolbarStates.EL_STYLE },
-    { label: '位置', key: ToolbarStates.EL_POSITION },
-    { label: '动画', key: ToolbarStates.EL_ANIMATION },
+    { label: "样式", key: ToolbarStates.EL_STYLE },
+    { label: "位置", key: ToolbarStates.EL_POSITION },
+    { label: "动画", key: ToolbarStates.EL_ANIMATION }
   ]
 })
 const slideTabs = [
-  { label: '设计', key: ToolbarStates.SLIDE_DESIGN },
-  { label: '切换', key: ToolbarStates.SLIDE_ANIMATION },
-  { label: '动画', key: ToolbarStates.EL_ANIMATION },
+  { label: "设计", key: ToolbarStates.SLIDE_DESIGN },
+  { label: "切换", key: ToolbarStates.SLIDE_ANIMATION },
+  { label: "动画", key: ToolbarStates.EL_ANIMATION }
 ]
 const multiSelectTabs = [
-  { label: '样式', key: ToolbarStates.EL_STYLE },
-  { label: '位置', key: ToolbarStates.MULTI_POSITION },
+  { label: "样式", key: ToolbarStates.EL_STYLE },
+  { label: "位置", key: ToolbarStates.MULTI_POSITION }
 ]
 
 const setToolbarState = (value: ToolbarStates) => {
@@ -85,7 +86,7 @@ const currentPanelComponent = computed(() => {
     [ToolbarStates.SLIDE_DESIGN]: SlideDesignPanel,
     [ToolbarStates.SLIDE_ANIMATION]: SlideAnimationPanel,
     [ToolbarStates.MULTI_POSITION]: MultiPositionPanel,
-    [ToolbarStates.SYMBOL]: SymbolPanel,
+    [ToolbarStates.SYMBOL]: SymbolPanel
   }
   return panelMap[toolbarState.value] || null
 })
@@ -104,4 +105,4 @@ const currentPanelComponent = computed(() => {
 
   @include overflow-overlay();
 }
-</style>
+</style>

+ 19 - 27
src/views/Editor/index.vue

@@ -6,11 +6,7 @@
       <div class="layout-content-center">
         <CanvasTool class="center-top" />
         <Canvas class="center-body" :style="{ height: `calc(100% - ${remarkHeight + 40}px)` }" />
-        <Remark
-          class="center-bottom"
-          v-model:height="remarkHeight"
-          :style="{ height: `${remarkHeight}px` }"
-        />
+        <Remark class="center-bottom" v-model:height="remarkHeight" :style="{ height: `${remarkHeight}px` }" />
       </div>
       <Toolbar class="layout-content-right" />
     </div>
@@ -20,37 +16,33 @@
   <SearchPanel v-if="showSearchPanel" />
   <NotesPanel v-if="showNotesPanel" />
 
-  <Modal
-    :visible="!!dialogForExport"
-    :width="680"
-    @closed="closeExportDialog()"
-  >
+  <Modal :visible="!!dialogForExport" :width="680" @closed="closeExportDialog()">
     <ExportDialog />
   </Modal>
 </template>
 
 <script lang="ts" setup>
-import { ref } from 'vue'
-import { storeToRefs } from 'pinia'
-import { useMainStore } from '@/store'
-import useGlobalHotkey from '@/hooks/useGlobalHotkey'
-import usePasteEvent from '@/hooks/usePasteEvent'
+import { ref } from "vue"
+import { storeToRefs } from "pinia"
+import { useMainStore } from "@/store"
+import useGlobalHotkey from "@/hooks/useGlobalHotkey"
+import usePasteEvent from "@/hooks/usePasteEvent"
 
-import EditorHeader from './EditorHeader/index.vue'
-import Canvas from './Canvas/index.vue'
-import CanvasTool from './CanvasTool/index.vue'
-import Thumbnails from './Thumbnails/index.vue'
-import Toolbar from './Toolbar/index.vue'
-import Remark from './Remark/index.vue'
-import ExportDialog from './ExportDialog/index.vue'
-import SelectPanel from './SelectPanel.vue'
-import SearchPanel from './SearchPanel.vue'
-import NotesPanel from './NotesPanel.vue'
-import Modal from '@/components/Modal.vue'
+import EditorHeader from "./EditorHeader/index.vue"
+import Canvas from "./Canvas/index.vue"
+import CanvasTool from "./CanvasTool/index.vue"
+import Thumbnails from "./Thumbnails/index.vue"
+import Toolbar from "./Toolbar/index.vue"
+import Remark from "./Remark/index.vue"
+import ExportDialog from "./ExportDialog/index.vue"
+import SelectPanel from "./SelectPanel.vue"
+import SearchPanel from "./SearchPanel.vue"
+import NotesPanel from "./NotesPanel.vue"
+import Modal from "@/components/Modal.vue"
 
 const mainStore = useMainStore()
 const { dialogForExport, showSelectPanel, showSearchPanel, showNotesPanel } = storeToRefs(mainStore)
-const closeExportDialog = () => mainStore.setDialogForExport('')
+const closeExportDialog = () => mainStore.setDialogForExport("")
 
 const remarkHeight = ref(40)
 

+ 24 - 26
src/views/Screen/ScreenElement.vue

@@ -1,39 +1,37 @@
 <template>
-  <div 
+  <div
     class="screen-element"
-    :class="{ 'link': elementInfo.link }"
+    :class="{ link: elementInfo.link }"
     :id="`screen-element-${elementInfo.id}`"
     :style="{
       zIndex: elementIndex,
       color: theme.fontColor,
       fontFamily: theme.fontName,
-      visibility: needWaitAnimation ? 'hidden' : 'visible',
+      visibility: needWaitAnimation ? 'hidden' : 'visible'
     }"
     :title="elementInfo.link?.target || ''"
     @click="$event => openLink($event)"
   >
-    <component
-      :is="currentElementComponent"
-      :elementInfo="elementInfo"
-    ></component>
+    <component :is="currentElementComponent" :elementInfo="elementInfo"></component>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { computed } from 'vue'
-import { storeToRefs } from 'pinia'
-import { useSlidesStore } from '@/store'
-import { ElementTypes, type PPTElement } from '@/types/slides'
+import { computed } from "vue"
+import { storeToRefs } from "pinia"
+import { useSlidesStore } from "@/store"
+import { ElementTypes, type PPTElement } from "@/types/slides"
 
-import BaseImageElement from '@/views/components/element/ImageElement/BaseImageElement.vue'
-import BaseTextElement from '@/views/components/element/TextElement/BaseTextElement.vue'
-import BaseShapeElement from '@/views/components/element/ShapeElement/BaseShapeElement.vue'
-import BaseLineElement from '@/views/components/element/LineElement/BaseLineElement.vue'
-import BaseChartElement from '@/views/components/element/ChartElement/BaseChartElement.vue'
-import BaseTableElement from '@/views/components/element/TableElement/BaseTableElement.vue'
-import BaseLatexElement from '@/views/components/element/LatexElement/BaseLatexElement.vue'
-import ScreenVideoElement from '@/views/components/element/VideoElement/ScreenVideoElement.vue'
-import ScreenAudioElement from '@/views/components/element/AudioElement/ScreenAudioElement.vue'
+import BaseImageElement from "@/views/components/element/ImageElement/BaseImageElement.vue"
+import BaseTextElement from "@/views/components/element/TextElement/BaseTextElement.vue"
+import BaseShapeElement from "@/views/components/element/ShapeElement/BaseShapeElement.vue"
+import BaseLineElement from "@/views/components/element/LineElement/BaseLineElement.vue"
+import BaseChartElement from "@/views/components/element/ChartElement/BaseChartElement.vue"
+import BaseTableElement from "@/views/components/element/TableElement/BaseTableElement.vue"
+import BaseLatexElement from "@/views/components/element/LatexElement/BaseLatexElement.vue"
+import ScreenVideoElement from "@/views/components/element/VideoElement/ScreenVideoElement.vue"
+import ScreenAudioElement from "@/views/components/element/AudioElement/ScreenAudioElement.vue"
+import ScreenCloudCoachElement from "@/views/components/element/cloudCoachElement/ScreenCloudCoachElement.vue"
 
 const props = defineProps<{
   elementInfo: PPTElement
@@ -54,6 +52,7 @@ const currentElementComponent = computed<unknown>(() => {
     [ElementTypes.LATEX]: BaseLatexElement,
     [ElementTypes.VIDEO]: ScreenVideoElement,
     [ElementTypes.AUDIO]: ScreenAudioElement,
+    [ElementTypes.CLOUDCOACH]: ScreenCloudCoachElement
   }
   return elementTypeMap[props.elementInfo.type] || null
 })
@@ -78,13 +77,13 @@ const needWaitAnimation = computed(() => {
   // 若该元素未执行过动画,获取其将要执行的第一个动画
   // 若将要执行的第一个动画为入场,则需要隐藏,否则无须隐藏
   const firstAnimation = formatedAnimations.value[elementIndexInAnimation].animations.find(item => item.elId === props.elementInfo.id)
-  if (firstAnimation?.type === 'in') return true
+  if (firstAnimation?.type === "in") return true
   return false
 })
 
 // 打开元素绑定的超链接
 const openLink = (e: MouseEvent) => {
-  if ((e.target as HTMLElement).tagName === 'A') {
+  if ((e.target as HTMLElement).tagName === "A") {
     props.manualExitFullscreen()
     return
   }
@@ -92,11 +91,10 @@ const openLink = (e: MouseEvent) => {
   const link = props.elementInfo.link
   if (!link) return
 
-  if (link.type === 'web') {
+  if (link.type === "web") {
     props.manualExitFullscreen()
     window.open(link.target)
-  }
-  else if (link.type === 'slide') {
+  } else if (link.type === "slide") {
     props.turnSlideToId(link.target)
   }
 }
@@ -106,4 +104,4 @@ const openLink = (e: MouseEvent) => {
 .link {
   cursor: pointer;
 }
-</style>
+</style>

+ 17 - 19
src/views/components/ThumbnailSlide/ThumbnailElement.vue

@@ -1,32 +1,29 @@
 <template>
-  <div 
+  <div
     class="base-element"
     :class="`base-element-${elementInfo.id}`"
     :style="{
-      zIndex: elementIndex,
+      zIndex: elementIndex
     }"
   >
-    <component
-      :is="currentElementComponent"
-      :elementInfo="elementInfo"
-      target="thumbnail"
-    ></component>
+    <component :is="currentElementComponent" :elementInfo="elementInfo" target="thumbnail" />
   </div>
 </template>
 
 <script lang="ts" setup>
-import { computed } from 'vue'
-import { ElementTypes, type PPTElement } from '@/types/slides'
+import { computed } from "vue"
+import { ElementTypes, type PPTElement } from "@/types/slides"
 
-import BaseImageElement from '@/views/components/element/ImageElement/BaseImageElement.vue'
-import BaseTextElement from '@/views/components/element/TextElement/BaseTextElement.vue'
-import BaseShapeElement from '@/views/components/element/ShapeElement/BaseShapeElement.vue'
-import BaseLineElement from '@/views/components/element/LineElement/BaseLineElement.vue'
-import BaseChartElement from '@/views/components/element/ChartElement/BaseChartElement.vue'
-import BaseTableElement from '@/views/components/element/TableElement/BaseTableElement.vue'
-import BaseLatexElement from '@/views/components/element/LatexElement/BaseLatexElement.vue'
-import BaseVideoElement from '@/views/components/element/VideoElement/BaseVideoElement.vue'
-import BaseAudioElement from '@/views/components/element/AudioElement/BaseAudioElement.vue'
+import BaseImageElement from "@/views/components/element/ImageElement/BaseImageElement.vue"
+import BaseTextElement from "@/views/components/element/TextElement/BaseTextElement.vue"
+import BaseShapeElement from "@/views/components/element/ShapeElement/BaseShapeElement.vue"
+import BaseLineElement from "@/views/components/element/LineElement/BaseLineElement.vue"
+import BaseChartElement from "@/views/components/element/ChartElement/BaseChartElement.vue"
+import BaseTableElement from "@/views/components/element/TableElement/BaseTableElement.vue"
+import BaseLatexElement from "@/views/components/element/LatexElement/BaseLatexElement.vue"
+import BaseVideoElement from "@/views/components/element/VideoElement/BaseVideoElement.vue"
+import BaseAudioElement from "@/views/components/element/AudioElement/BaseAudioElement.vue"
+import BaseCloudCoachElement from "@/views/components/element/cloudCoachElement/BaseCloudCoachElement.vue"
 
 const props = defineProps<{
   elementInfo: PPTElement
@@ -44,7 +41,8 @@ const currentElementComponent = computed<unknown>(() => {
     [ElementTypes.LATEX]: BaseLatexElement,
     [ElementTypes.VIDEO]: BaseVideoElement,
     [ElementTypes.AUDIO]: BaseAudioElement,
+    [ElementTypes.CLOUDCOACH]: BaseCloudCoachElement
   }
   return elementTypeMap[props.elementInfo.type] || null
 })
-</script>
+</script>

+ 24 - 25
src/views/components/ThumbnailSlide/index.vue

@@ -1,48 +1,47 @@
 <template>
-  <div class="thumbnail-slide"
+  <div
+    class="thumbnail-slide"
     :style="{
       width: size + 'px',
-      height: size * viewportRatio + 'px',
+      height: size * viewportRatio + 'px'
     }"
   >
-    <div 
+    <div
       class="elements"
       :style="{
         width: viewportSize + 'px',
         height: viewportSize * viewportRatio + 'px',
-        transform: `scale(${scale})`,
+        transform: `scale(${scale})`
       }"
       v-if="visible"
     >
       <div class="background" :style="backgroundStyle"></div>
-      <ThumbnailElement
-        v-for="(element, index) in slide.elements"
-        :key="element.id"
-        :elementInfo="element"
-        :elementIndex="index + 1"
-      />
+      <ThumbnailElement v-for="(element, index) in slide.elements" :key="element.id" :elementInfo="element" :elementIndex="index + 1" />
     </div>
     <div class="placeholder" v-else>加载中 ...</div>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { computed, provide } from 'vue'
-import { storeToRefs } from 'pinia'
-import { useSlidesStore } from '@/store'
-import type { Slide } from '@/types/slides'
-import { injectKeySlideScale } from '@/types/injectKey'
-import useSlideBackgroundStyle from '@/hooks/useSlideBackgroundStyle'
+import { computed, provide } from "vue"
+import { storeToRefs } from "pinia"
+import { useSlidesStore } from "@/store"
+import type { Slide } from "@/types/slides"
+import { injectKeySlideScale } from "@/types/injectKey"
+import useSlideBackgroundStyle from "@/hooks/useSlideBackgroundStyle"
 
-import ThumbnailElement from './ThumbnailElement.vue'
+import ThumbnailElement from "./ThumbnailElement.vue"
 
-const props = withDefaults(defineProps<{
-  slide: Slide
-  size: number
-  visible?: boolean
-}>(), {
-  visible: true,
-})
+const props = withDefaults(
+  defineProps<{
+    slide: Slide
+    size: number
+    visible?: boolean
+  }>(),
+  {
+    visible: true
+  }
+)
 
 const { viewportRatio, viewportSize } = storeToRefs(useSlidesStore())
 
@@ -75,4 +74,4 @@ provide(injectKeySlideScale, scale)
   justify-content: center;
   align-items: center;
 }
-</style>
+</style>

+ 19 - 21
src/views/components/element/AudioElement/ScreenAudioElement.vue

@@ -1,22 +1,20 @@
 <template>
-  <div class="base-element-audio screen-element-audio"
+  <div
+    class="base-element-audio screen-element-audio"
     :style="{
       top: elementInfo.top + 'px',
       left: elementInfo.left + 'px',
       width: elementInfo.width + 'px',
-      height: elementInfo.height + 'px',
+      height: elementInfo.height + 'px'
     }"
   >
-    <div
-      class="rotate-wrapper"
-      :style="{ transform: `rotate(${elementInfo.rotate}deg)` }"
-    >
+    <div class="rotate-wrapper" :style="{ transform: `rotate(${elementInfo.rotate}deg)` }">
       <div class="element-content">
-        <IconVolumeNotice 
-          class="audio-icon" 
+        <IconVolumeNotice
+          class="audio-icon"
           :style="{
             fontSize: audioIconSize,
-            color: elementInfo.color,
+            color: elementInfo.color
           }"
           @click="toggle()"
         />
@@ -25,7 +23,7 @@
           ref="audioPlayerRef"
           v-if="inCurrentSlide"
           :style="{ ...audioPlayerPosition }"
-          :src="elementInfo.src" 
+          :src="elementInfo.src"
           :loop="elementInfo.loop"
           :autoplay="elementInfo.autoplay"
           :scale="scale"
@@ -36,13 +34,13 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, inject, ref } from 'vue'
-import { storeToRefs } from 'pinia'
-import { useSlidesStore } from '@/store'
-import type { PPTAudioElement } from '@/types/slides'
-import { injectKeySlideId, injectKeySlideScale } from '@/types/injectKey'
+import { computed, inject, ref } from "vue"
+import { storeToRefs } from "pinia"
+import { useSlidesStore } from "@/store"
+import type { PPTAudioElement } from "@/types/slides"
+import { injectKeySlideId, injectKeySlideScale } from "@/types/injectKey"
 
-import AudioPlayer from './AudioPlayer.vue'
+import AudioPlayer from "./AudioPlayer.vue"
 
 const props = defineProps<{
   elementInfo: PPTAudioElement
@@ -51,12 +49,12 @@ const props = defineProps<{
 const { viewportRatio, currentSlide, viewportSize } = storeToRefs(useSlidesStore())
 
 const scale = inject(injectKeySlideScale) || ref(1)
-const slideId = inject(injectKeySlideId) || ref('')
+const slideId = inject(injectKeySlideId) || ref("")
 
 const inCurrentSlide = computed(() => currentSlide.value.id === slideId.value)
 
 const audioIconSize = computed(() => {
-  return Math.min(props.elementInfo.width, props.elementInfo.height) + 'px'
+  return Math.min(props.elementInfo.width, props.elementInfo.height) + "px"
 })
 const audioPlayerPosition = computed(() => {
   const canvasWidth = viewportSize.value
@@ -72,13 +70,13 @@ const audioPlayerPosition = computed(() => {
 
   let left = 0
   let top = elHeight
-  
+
   if (elLeft + audioWidth >= canvasWidth) left = elWidth - audioWidth
   if (elTop + elHeight + audioHeight >= canvasHeight) top = -audioHeight
 
   return {
-    left: left + 'px',
-    top: top + 'px',
+    left: left + "px",
+    top: top + "px"
   }
 })
 

+ 47 - 0
src/views/components/element/cloudCoachElement/BaseCloudCoachElement.vue

@@ -0,0 +1,47 @@
+<template>
+  <div
+    class="base-element-cloudCoach"
+    :style="{
+      top: elementInfo.top + 'px',
+      left: elementInfo.left + 'px',
+      width: elementInfo.width + 'px',
+      height: elementInfo.height + 'px'
+    }"
+  >
+    <div class="rotate-wrapper" :style="{ transform: `rotate(${elementInfo.rotate}deg)` }">
+      <div class="element-content">
+        <div class="text">云教练</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import type { PPTCloudCoachElement } from "@/types/slides"
+
+defineProps<{
+  elementInfo: PPTCloudCoachElement
+}>()
+</script>
+
+<style lang="scss" scoped>
+.base-element-cloudCoach {
+  position: absolute;
+}
+.rotate-wrapper {
+  width: 100%;
+  height: 100%;
+}
+.element-content {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: #213793;
+  .text {
+    color: #fff;
+    font-size: 80px;
+  }
+}
+</style>

+ 51 - 0
src/views/components/element/cloudCoachElement/ScreenCloudCoachElement.vue

@@ -0,0 +1,51 @@
+<template>
+  <div
+    class="base-element-cloudCoach screen-element-cloudCoach"
+    :style="{
+      top: elementInfo.top + 'px',
+      left: elementInfo.left + 'px',
+      width: elementInfo.width + 'px',
+      height: elementInfo.height + 'px'
+    }"
+  >
+    <div class="rotate-wrapper" :style="{ transform: `rotate(${elementInfo.rotate}deg)` }">
+      <div class="element-content">
+        <cloudCoachPlayer v-if="inCurrentSlide" :url="elementInfo.url" :width="elementInfo.width" :height="elementInfo.height" :scale="scale" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, ref } from "vue"
+import { storeToRefs } from "pinia"
+import { useSlidesStore } from "@/store"
+import type { PPTCloudCoachElement } from "@/types/slides"
+import { injectKeySlideId, injectKeySlideScale } from "@/types/injectKey"
+import cloudCoachPlayer from "./cloudCoachPlayer"
+
+defineProps<{
+  elementInfo: PPTCloudCoachElement
+}>()
+
+const { currentSlide } = storeToRefs(useSlidesStore())
+
+const scale = inject(injectKeySlideScale) || ref(1)
+const slideId = inject(injectKeySlideId) || ref("")
+
+const inCurrentSlide = computed(() => currentSlide.value.id === slideId.value)
+</script>
+
+<style lang="scss" scoped>
+.screen-element-cloudCoach {
+  position: absolute;
+}
+.rotate-wrapper {
+  width: 100%;
+  height: 100%;
+}
+.element-content {
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 98 - 0
src/views/components/element/cloudCoachElement/cloudCoachElement.vue

@@ -0,0 +1,98 @@
+<template>
+  <div
+    class="editable-element-cloudCoach"
+    :class="{ lock: elementInfo.lock }"
+    :style="{
+      top: elementInfo.top + 'px',
+      left: elementInfo.left + 'px',
+      width: elementInfo.width + 'px',
+      height: elementInfo.height + 'px'
+    }"
+  >
+    <div class="rotate-wrapper" :style="{ transform: `rotate(${elementInfo.rotate}deg)` }">
+      <div
+        class="element-content"
+        v-contextmenu="contextmenus"
+        @mousedown="$event => handleSelectElement($event)"
+        @touchstart="$event => handleSelectElement($event)"
+      >
+        <cloudCoachPlayer :url="elementInfo.url" :width="elementInfo.width" :height="elementInfo.height" :scale="canvasScale" />
+        <div
+          :class="['handler-border', item]"
+          v-for="item in ['t', 'b', 'l', 'r']"
+          :key="item"
+          @mousedown="$event => handleSelectElement($event)"
+          @touchstart="$event => handleSelectElement($event)"
+        ></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { storeToRefs } from "pinia"
+import { useMainStore } from "@/store"
+import type { PPTCloudCoachElement } from "@/types/slides"
+import type { ContextmenuItem } from "@/components/Contextmenu/types"
+import cloudCoachPlayer from "./cloudCoachPlayer"
+
+const props = defineProps<{
+  elementInfo: PPTCloudCoachElement
+  selectElement: (e: MouseEvent | TouchEvent, element: PPTCloudCoachElement, canMove?: boolean) => void
+  contextmenus: () => ContextmenuItem[] | null
+}>()
+
+const { canvasScale } = storeToRefs(useMainStore())
+
+const handleSelectElement = (e: MouseEvent | TouchEvent, canMove = true) => {
+  if (props.elementInfo.lock) return
+  e.stopPropagation()
+
+  props.selectElement(e, props.elementInfo, canMove)
+}
+</script>
+
+<style lang="scss" scoped>
+.editable-element-cloudCoach {
+  position: absolute;
+  &.lock .handler-border {
+    cursor: default;
+  }
+}
+.rotate-wrapper {
+  width: 100%;
+  height: 100%;
+}
+.element-content {
+  width: 100%;
+  height: 100%;
+}
+.handler-border {
+  position: absolute;
+  cursor: move;
+  &.t {
+    width: 100%;
+    height: 20px;
+    top: 0;
+    left: 0;
+  }
+  &.b {
+    width: 100%;
+    height: 5px;
+    bottom: 0;
+    left: 0;
+  }
+  &.l {
+    width: 10px;
+    height: 100%;
+    left: 0;
+    top: 0;
+  }
+  &.r {
+    width: 10px;
+    height: 100%;
+    right: 0;
+    top: 0;
+  }
+}
+</style>

+ 22 - 0
src/views/components/element/cloudCoachElement/cloudCoachList/cloudCoachList.vue

@@ -0,0 +1,22 @@
+<template>
+  <div class="cloudCoachList">
+    <ElButton @click="handleUpdate">点击云教练</ElButton>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ElButton } from "element-plus"
+const emit = defineEmits<{
+  (event: "update", id: string): void
+}>()
+function handleUpdate() {
+  emit("update", "1760123974848413697")
+}
+</script>
+
+<style lang="scss" scoped>
+.cloudCoachList {
+  width: 100%;
+  height: 200px;
+}
+</style>

+ 2 - 0
src/views/components/element/cloudCoachElement/cloudCoachList/index.ts

@@ -0,0 +1,2 @@
+import cloudCoachList from "./cloudCoachList.vue"
+export default cloudCoachList

+ 86 - 0
src/views/components/element/cloudCoachElement/cloudCoachPlayer/cloudCoachPlayer.vue

@@ -0,0 +1,86 @@
+<template>
+  <div
+    class="cloudCoachPlayer"
+    :style="{
+      width: width * scale + 'px',
+      height: height * scale + 'px',
+      transform: `scale(${1 / scale})`
+    }"
+  >
+    <div v-if="loading" class="loading-overlay">
+      <div class="spinner"></div>
+      <div class="text">云教练加载中...</div>
+    </div>
+    <iframe class="musicIframe" frameborder="0" :src="url" @load="handleIframeLoad"></iframe>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from "vue"
+
+const props = withDefaults(
+  defineProps<{
+    width: number
+    height: number
+    url: string
+    scale?: number
+  }>(),
+  {
+    scale: 1
+  }
+)
+
+const loading = ref(true)
+function handleIframeLoad() {
+  loading.value = false
+}
+</script>
+
+<style lang="scss" scoped>
+.cloudCoachPlayer {
+  width: 100%;
+  height: 100%;
+  position: relative;
+  overflow: hidden;
+  user-select: none;
+  line-height: 1;
+  transform-origin: 0 0;
+  .musicIframe {
+    width: 100%;
+    height: 100%;
+  }
+  .loading-overlay {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    flex-direction: column;
+    z-index: 10;
+    color: #fff;
+    background-color: #213793;
+    .spinner {
+      border: 4px solid #f3f3f3;
+      border-top: 4px solid #213793;
+      border-radius: 50%;
+      width: 40px;
+      height: 40px;
+      animation: spin 1s linear infinite;
+    }
+    .text {
+      margin-top: 10px;
+    }
+    @keyframes spin {
+      0% {
+        transform: rotate(0deg);
+      }
+      100% {
+        transform: rotate(360deg);
+      }
+    }
+  }
+}
+</style>

+ 2 - 0
src/views/components/element/cloudCoachElement/cloudCoachPlayer/index.ts

@@ -0,0 +1,2 @@
+import cloudCoachPlayer from "./cloudCoachPlayer.vue"
+export default cloudCoachPlayer

+ 2 - 0
src/views/components/element/cloudCoachElement/index.ts

@@ -0,0 +1,2 @@
+import cloudCoachElement from "./cloudCoachElement.vue"
+export default cloudCoachElement