Browse Source

Merge branch 'beta' into hk

lsc 5 days ago
parent
commit
5442149292

+ 8 - 7
package-lock.json

@@ -28,7 +28,7 @@
         "number-precision": "^1.6.0",
         "pinia": "^3.0.2",
         "pptxgenjs": "^3.12.0",
-        "pptxtojson": "^1.5.2",
+        "pptxtojson": "1.12.0",
         "prosemirror-commands": "^1.6.0",
         "prosemirror-dropcursor": "^1.8.1",
         "prosemirror-gapcursor": "^1.3.2",
@@ -4592,9 +4592,10 @@
       }
     },
     "node_modules/pptxtojson": {
-      "version": "1.5.2",
-      "resolved": "https://registry.npmjs.org/pptxtojson/-/pptxtojson-1.5.2.tgz",
-      "integrity": "sha512-8kvZUr92qHOBUUchUg1c9hMU/lwcIUEoDLf0wEwoM3FenOfNYRPxlnbPYLcngzvShhRgwAGmp4j/nmCGf3+VMA==",
+      "version": "1.12.0",
+      "resolved": "https://registry.npmmirror.com/pptxtojson/-/pptxtojson-1.12.0.tgz",
+      "integrity": "sha512-kXdCqXlCLdiGpM3womaQIpGYrQEzl8/jdxSpWWtYNj3Pxvr9n39tGpuBxeZSdenttwzM7Fb/yBKUPzxMFXLIJQ==",
+      "license": "MIT",
       "dependencies": {
         "jszip": "^3.10.1",
         "tinycolor2": "1.6.0",
@@ -9303,9 +9304,9 @@
       }
     },
     "pptxtojson": {
-      "version": "1.5.2",
-      "resolved": "https://registry.npmjs.org/pptxtojson/-/pptxtojson-1.5.2.tgz",
-      "integrity": "sha512-8kvZUr92qHOBUUchUg1c9hMU/lwcIUEoDLf0wEwoM3FenOfNYRPxlnbPYLcngzvShhRgwAGmp4j/nmCGf3+VMA==",
+      "version": "1.12.0",
+      "resolved": "https://registry.npmmirror.com/pptxtojson/-/pptxtojson-1.12.0.tgz",
+      "integrity": "sha512-kXdCqXlCLdiGpM3womaQIpGYrQEzl8/jdxSpWWtYNj3Pxvr9n39tGpuBxeZSdenttwzM7Fb/yBKUPzxMFXLIJQ==",
       "requires": {
         "jszip": "^3.10.1",
         "tinycolor2": "1.6.0",

+ 1 - 1
package.json

@@ -35,7 +35,7 @@
     "number-precision": "^1.6.0",
     "pinia": "^3.0.2",
     "pptxgenjs": "^3.12.0",
-    "pptxtojson": "^1.5.2",
+    "pptxtojson": "1.12.0",
     "prosemirror-commands": "^1.6.0",
     "prosemirror-dropcursor": "^1.8.1",
     "prosemirror-gapcursor": "^1.3.2",

+ 6 - 0
src/App.vue

@@ -3,6 +3,7 @@
     <Screen v-if="viewMode !== 'student'" v-show="screening"/>
     <Editor v-if="viewMode === 'editor'" v-show="_isPC && !screening" :courseid="urlParams.courseid"/>
     <Editor2 v-else-if="viewMode === 'editor2'" v-show="_isPC && !screening" :courseid="urlParams.courseid"/>
+    <Editor3 v-else-if="viewMode === 'editor3'" v-show="_isPC && !screening" :courseid="urlParams.courseid"/>
     <Student v-else-if="viewMode === 'student'" :courseid="urlParams.courseid" :type="urlParams.type" :userid="urlParams.userid" :oid="urlParams.oid" :org="urlParams.org" :cid="urlParams.cid" />
     <Mobile v-else />
   </template>
@@ -22,6 +23,7 @@ import api from '@/services'
 
 import Editor from './views/Editor/index.vue'
 import Editor2 from './views/Editor/index2.vue'
+import Editor3 from './views/Editor/index3.vue'
 import Screen from './views/Screen/index.vue'
 import Mobile from './views/Mobile/index.vue'
 import Student from './views/Student/index.vue'
@@ -50,6 +52,10 @@ const getInitialViewMode = () => {
   if (modeFromUrl === 'editor2') {
     return 'editor2'
   }
+
+  if (modeFromUrl === 'editor3') {
+    return 'editor3'
+  }
   // 检查localStorage
   const modeFromStorage = localStorage.getItem('viewMode')
   if (modeFromStorage) {

+ 1 - 2
src/assets/styles/prosemirror.scss

@@ -59,8 +59,7 @@
     font-size: smaller;
   }
   sub {
-    vertical-align: sub;
-    font-size: smaller;
+    vertical-align: baseline;
   }
 
   blockquote {

+ 30 - 4
src/components/CollapsibleToolbar/index.vue

@@ -41,7 +41,7 @@
           </svg>
           <span class="item-label">创作空间</span>
         </div>
-        <div class="sidebar-item" :class="{ active: activeSubmenu === 'contentlist' }" @click="toggleSubmenu('contentlist')">
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'contentlist' }" @click="toggleSubmenu('contentlist')" v-show="false">
           <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
             <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
             <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
@@ -126,7 +126,10 @@
 
 <script lang="ts" setup>
 import { ref } from 'vue'
+import { storeToRefs } from 'pinia'
 import useCreateElement from '@/hooks/useCreateElement'
+import useSlideHandler from '@/hooks/useSlideHandler'
+import { useSlidesStore } from '@/store'
 
 interface ContentItem {
   tool?: number
@@ -149,7 +152,11 @@ const isCollapsed = ref(props.defaultCollapsed)
 const activeSubmenu = ref<string | null>(null)
 const contentList = ref<ContentItem[]>([])
 
+const slidesStore = useSlidesStore()
+const { currentSlide } = storeToRefs(slidesStore)
+
 const { createFrameElement } = useCreateElement()
+const { createSlide } = useSlideHandler()
 
 const toggleCollapse = () => {
   isCollapsed.value = !isCollapsed.value
@@ -210,13 +217,33 @@ const loadContentList = () => {
   }
 }
 
-(window as any).loadContentList = loadContentList
-
 const insertContent = (item: ContentItem) => {
   if (!item.tool || !item.url) return
   createFrameElement(item.url, item.tool)
 }
 
+const addContent = (data: ContentItem, type: number) => {
+  // contentList.value.push(data)
+  if (type === 2) {
+    const elements = currentSlide.value?.elements || []
+    const frameElement = elements.find((el: any) => el.type === 'frame' && (el.toolType === 45 || el.toolType === 15))
+    if (frameElement) {
+      slidesStore.updateElement({
+        id: frameElement.id,
+        props: { url: data.url }
+      })
+    }
+  }
+  else {
+    createSlide()
+    insertContent(data)
+  }
+}
+
+Object.assign(window, { addContent, loadContentList })
+// window.loadContentList = loadContentList
+// window.addContent = addContent
+
 const previewVideo = (item: ContentItem) => {
   interface ParentWindowWithToolList extends Window {
     previewVideo?: (item: ContentItem) => void;
@@ -356,7 +383,6 @@ const getTypeClass = (type?: number) => {
   font-weight: 500;
   color: #6b7280;
   text-align: center;
-  line-height: 1.2;
 }
 
 .sidebar-item:hover .item-label,

+ 253 - 0
src/components/CreateCourseDialog.vue

@@ -0,0 +1,253 @@
+<template>
+  <div class="create-course-dialog">
+    <div class="dialog-header">
+      <button class="close-btn" @click="$emit('close')">
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <line x1="18" y1="6" x2="6" y2="18"/>
+          <line x1="6" y1="6" x2="18" y2="18"/>
+        </svg>
+      </button>
+    </div>
+    <div class="dialog-content">
+      <h2>创建新课程</h2>
+      <p class="subtitle">选择一种方式开始创建您的互动课件</p>
+      <div class="options-grid">
+        <div class="option-card disabled">
+          <div class="option-icon">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M12 2L2 7l10 5 10-5-10-5z" />
+              <path d="M2 17l10 5 10-5" />
+              <path d="M2 12l10 5 10-5" />
+            </svg>
+          </div>
+          <h3>从AI创建</h3>
+          <p>AI自动生成完整教学内容</p>
+          <div class="coming-soon">待上线</div>
+        </div>
+        <FileInput accept="application/vnd.openxmlformats-officedocument.presentationml.presentation" @change="files => {
+          importPPTXFile(files)
+          $emit('close')
+        }">
+          <div class="option-card">
+            <div class="option-icon">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
+                <polyline points="17 8 12 3 7 8" />
+                <line x1="12" y1="3" x2="12" y2="15" />
+              </svg>
+            </div>
+            <h3>上传本地文件</h3>
+            <p>上传本地PPT文件并解析</p>
+          </div>
+        </FileInput>
+        <div class="option-card disabled">
+          <div class="option-icon">
+            <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M3.5 10.5007L14 2.33398L24.5 10.5007V23.334C24.5 23.9528 24.2542 24.5463 23.8166 24.9839C23.379 25.4215 22.7855 25.6673 22.1667 25.6673H5.83333C5.21449 25.6673 4.621 25.4215 4.18342 24.9839C3.74583 24.5463 3.5 23.9528 3.5 23.334V10.5007Z"
+                  stroke="currentColor" stroke-width="2.33333" />
+                <path id="Vector_2" d="M10.5 25.6667V14H17.5V25.6667" stroke="currentColor" stroke-width="2.33333" />
+              </g>
+            </svg>
+          </div>
+          <h3>从资源库导入</h3>
+          <p>选择已有的课程资源</p>
+          <div class="coming-soon">待上线</div>
+        </div>
+        <div class="option-card" @click="handleOptionClick('blank')">
+          <div class="option-icon">
+            <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M16.3332 2.33398H6.99984C6.381 2.33398 5.78751 2.57982 5.34992 3.0174C4.91234 3.45499 4.6665 4.04848 4.6665 4.66732V23.334C4.6665 23.9528 4.91234 24.5463 5.34992 24.9839C5.78751 25.4215 6.381 25.6673 6.99984 25.6673H20.9998C21.6187 25.6673 22.2122 25.4215 22.6498 24.9839C23.0873 24.5463 23.3332 23.9528 23.3332 23.334V9.33398L16.3332 2.33398Z"
+                  stroke="currentColor" stroke-width="2.33333" />
+                <path id="Vector_2" d="M16.3335 2.33398V9.33398H23.3335" stroke="currentColor" stroke-width="2.33333" />
+              </g>
+            </svg>
+          </div>
+          <h3>创建空白</h3>
+          <p>从零开始定义</p>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import useImport from '@/hooks/useImport'
+import FileInput from '@/components/FileInput.vue'
+
+const emit = defineEmits<{
+  (e: 'close'): void
+  (e: 'select', option: string): void
+}>()
+
+const { importPPTXFile } = useImport()
+
+const handleOptionClick = (option: string) => {
+  emit('select', option)
+  emit('close')
+}
+</script>
+
+<style lang="scss" scoped>
+.create-course-dialog {
+  width: 100%;
+  max-width: 800px;
+  margin: 0 auto;
+
+  .dialog-header {
+    display: flex;
+    justify-content: flex-end;
+    align-items: center;
+    margin-bottom: 0;
+
+    .close-btn {
+      width: 32px;
+      height: 32px;
+      border: none;
+      background: none;
+      cursor: pointer;
+      color: #999;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      border-radius: 4px;
+      transition: all 0.2s;
+
+      &:hover {
+        background: #f0f0f0;
+        color: #666;
+      }
+
+      svg {
+        width: 16px;
+        height: 16px;
+      }
+    }
+  }
+
+  .dialog-content {
+    h2 {
+      font-size: 24px;
+      font-weight: 600;
+      color: #333;
+      margin: 0 auto 20px;
+      text-align: center;
+    }
+
+    .subtitle {
+      text-align: center;
+      color: #666;
+      margin-bottom: 32px;
+      font-size: 14px;
+    }
+
+    .options-grid {
+      display: grid;
+      grid-template-columns: repeat(2, 1fr);
+      gap: 20px;
+
+      .option-card {
+          background: #fafbfc;
+          border: 1px solid #E5E7EB;
+          border-radius: 12px;
+          padding: 24px;
+          text-align: center;
+          cursor: pointer;
+          transition: all 0.3s;
+          position: relative;
+
+          &:hover {
+            border-color: #FF9300;
+            // box-shadow: 0 4px 12px rgba(255, 147, 0, 0.15);
+            background: #FFFAF0;
+
+            .option-icon {
+              color: #FF9300;
+            }
+          }
+
+          &.active {
+            background: #FFFAF0;
+            border-color: #FF9300;
+          }
+
+          &.disabled {
+            background: #f8f8f9;
+            border-color: #eff0f3;
+            cursor: not-allowed;
+
+            h3 {
+              color: #7c7f86;
+            }
+
+            p {
+              color: #b5b9bf;
+            }
+
+            .option-icon {
+              color: #a9aeb5;
+              background: #fff;
+            }
+
+            // &:hover {
+            //   border-color: #E5E7EB;
+            //   box-shadow: none;
+            //   background: #F3F4F6;
+
+            //   .option-icon {
+            //     color: #D1D5DB;
+            //   }
+            // }
+          }
+
+          .option-icon {
+            width: 48px;
+            height: 48px;
+            background: #eef3ff;
+            border-radius: 12px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            margin: 0 auto 16px;
+            color: #6b7280;
+            transition: all 0.3s;
+
+            svg {
+              width: 24px;
+              height: 24px;
+            }
+          }
+
+          h3 {
+            font-size: 18px;
+            font-weight: 600;
+            color: #333;
+            margin: 0 0 8px;
+          }
+
+          p {
+            font-size: 14px;
+            color: #999;
+            margin: 0 0 16px;
+          }
+
+          .coming-soon {
+            position: absolute;
+            top: 12px;
+            right: 12px;
+            background: #c5c9d0;
+            color: #fff;
+            font-size: 14px;
+            font-weight: 500;
+            padding: 4px 8px;
+            border-radius: 15px;
+            text-transform: uppercase;
+          }
+        }
+    }
+  }
+}
+</style>

+ 4 - 1
src/components/Modal.vue

@@ -1,7 +1,7 @@
 <template>
   <Teleport to="body">
     <Transition name="modal-fade">
-      <div class="modal" ref="modalRef" v-show="visible" tabindex="-1" @keyup.esc="onEsc()">
+      <div class="modal" :class="modalClass" ref="modalRef" v-show="visible" tabindex="-1" @keyup.esc="onEsc()">
         <div class="mask" @click="onClickMask()"></div>
         <Transition name="modal-zoom"
           @afterLeave="contentVisible = false"
@@ -30,11 +30,13 @@ const props = withDefaults(defineProps<{
   closeOnClickMask?: boolean
   closeOnEsc?: boolean
   contentStyle?: CSSProperties
+  class?: string
 }>(), {
   width: 480,
   closeButton: false,
   closeOnClickMask: true,
   closeOnEsc: true,
+  class: '',
 })
 
 const modalRef = useTemplateRef<HTMLDivElement>('modalRef')
@@ -45,6 +47,7 @@ const emit = defineEmits<{
 }>()
 
 const contentVisible = ref(false)
+const modalClass = computed(() => props.class)
 
 const contentStyle = computed(() => {
   return {

+ 49 - 0
src/global.d.ts

@@ -16,6 +16,7 @@ interface Document {
 }
 
 // AWS SDK 类型声明
+/*
 interface Window {
   AWS: {
     config: {
@@ -26,4 +27,52 @@ interface Window {
       getObject: (params: { Bucket: string; Key: string }, callback: (err: any, data: any) => void) => void
     }
   }
+}
+*/
+
+interface Window {
+  AWS: {
+    config: {
+      update: (credentials: { accessKeyId: string; secretAccessKey: string }) => void;
+      region: string;
+    };
+    S3: new (config: { params: { Bucket: string } }) => S3Instance;
+  };
+}
+
+// 定义 S3 实例的方法
+interface S3Instance {
+  getObject(params: { Bucket: string; Key: string }, callback: (err: any, data: any) => void): void;
+  upload(params: S3UploadParams, options?: S3UploadOptions): S3ManagedUpload;
+}
+
+// upload 方法的参数
+interface S3UploadParams {
+  Key: string;
+  ContentType: string;
+  Body: File | Blob;
+  ACL?: string;
+  // 其他可选参数...
+}
+
+// upload 方法的选项
+interface S3UploadOptions {
+  partSize?: number;
+  queueSize?: number;
+  leavePartsOnError?: boolean;
+}
+
+// upload 返回的管理对象
+interface S3ManagedUpload {
+  promise(): Promise<S3UploadResult>;
+  on(event: string, listener: (...args: any[]) => void): this;
+  send(callback: (err: any, data: any) => void): void;
+}
+
+// upload 成功返回的数据
+interface S3UploadResult {
+  Location: string;
+  ETag: string;
+  Bucket: string;
+  Key: string;
 }

+ 884 - 12
src/hooks/useImport.ts

@@ -21,14 +21,33 @@ import type {
   PPTImageElement,
   ShapeTextAlign,
   PPTTextElement,
+  PPTVideoElement,
+  PPTAudioElement,
   ChartOptions,
   Gradient,
 } from '@/types/slides'
 
 const convertFontSizePtToPx = (html: string, ratio: number) => {
-  return html.replace(/font-size:\s*([\d.]+)pt/g, (match, p1) => {
-    return `font-size: ${(parseFloat(p1) * ratio).toFixed(1)}px`
+  //return html;
+  return html.replace(/\s*([\d.]+)pt/g, (match, p1) => {
+    return `${(parseFloat(p1) * ratio - 1) | 0}px `
   })
+
+}
+
+const getStyle = (htmlString: string) => {
+  //return html;
+  // 1. 创建 DOMParser 实例
+  const parser = new DOMParser();
+  // 2. 解析 HTML 字符串为文档对象
+  const doc = parser.parseFromString(htmlString, 'text/html');
+  // 3. 获取 p 元素
+  const p = doc.querySelector('p');
+  // 4. 读取 style 属性(内联样式字符串)
+  const styleAttr = p?.getAttribute('allstyle');
+  console.log(styleAttr); // 输出完整的 style 字符串
+  return styleAttr || "";
+
 }
 
 export default () => {
@@ -380,6 +399,140 @@ export default () => {
     return { x: graphicX, y: graphicY }
   }
 
+  /**
+   * 将 base64 字符串或 Blob 转换为 File 对象
+   */
+  const dataToFile = (data: string | Blob, filename: string, mimeType?: string): File => {
+    if (typeof data === 'string') {
+      // 移除可能的 data:image/png;base64, 前缀
+      const base64Data = data.includes('base64,') ? data.split('base64,')[1] : data;
+      const byteCharacters = atob(base64Data);
+      const byteNumbers = new Array(byteCharacters.length);
+      for (let i = 0; i < byteCharacters.length; i++) {
+        byteNumbers[i] = byteCharacters.charCodeAt(i);
+      }
+      const byteArray = new Uint8Array(byteNumbers);
+      const blob = new Blob([byteArray], { type: mimeType || 'image/png' });
+      return new File([blob], filename, { type: blob.type });
+    } else if (data instanceof Blob) {
+      return new File([data], filename, { type: data.type });
+    }
+    throw new Error('Unsupported data type');
+  };
+
+  const makeWhiteTransparent = async (
+    data: string | Blob,
+    filename: string,
+    options?: { tolerance?: number }
+  ): Promise<File> => {
+    const tolerance = options?.tolerance ?? 30; // 容差值,控制哪些颜色被视为白色
+  
+    // 1. 将输入数据统一为 Blob 或可直接用于加载的 URL
+    let imageUrl: string;
+    let blob: Blob;
+  
+    if (typeof data === 'string') {
+      // 如果是 Base64,直接用作 src(data URL)
+      imageUrl = data.startsWith('data:') ? data : `data:image/png;base64,${data}`;
+    } else if (data instanceof Blob) {
+      // 如果是 Blob,创建对象 URL
+      imageUrl = URL.createObjectURL(data);
+      blob = data; // 暂存,后续释放 URL 用
+    } else {
+      throw new Error('Unsupported data type');
+    }
+  
+    // 2. 加载图像到 Image 元素
+    const img = await new Promise<HTMLImageElement>((resolve, reject) => {
+      const image = new Image();
+      image.onload = () => resolve(image);
+      image.onerror = reject;
+      image.src = imageUrl;
+      // 如果图像来自跨域,可能需要设置 crossOrigin
+      // image.crossOrigin = 'anonymous';
+    });
+  
+    // 3. 创建 Canvas 并绘制图像
+    const canvas = document.createElement('canvas');
+    canvas.width = img.width;
+    canvas.height = img.height;
+    const ctx = canvas.getContext('2d')!;
+    ctx.drawImage(img, 0, 0);
+  
+    // 4. 获取像素数据并处理
+    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+    const dataArray = imageData.data;
+  
+    for (let i = 0; i < dataArray.length; i += 4) {
+      const r = dataArray[i];
+      const g = dataArray[i + 1];
+      const b = dataArray[i + 2];
+  
+      // 判断颜色是否接近白色(RGB 都大于 255 - tolerance)
+      if (r > 255 - tolerance && g > 255 - tolerance && b > 255 - tolerance) {
+        dataArray[i + 3] = 0; // 设置 Alpha 为 0(完全透明)
+      }
+    }
+  
+    // 5. 将修改后的像素放回 Canvas
+    ctx.putImageData(imageData, 0, 0);
+  
+    // 6. 将 Canvas 转换为 PNG Blob
+    const outputBlob = await new Promise<Blob>((resolve) =>
+      canvas.toBlob((blob) => resolve(blob!), 'image/png')
+    );
+  
+    // 7. 清理对象 URL(如果之前创建过)
+    if (typeof data !== 'string') {
+      URL.revokeObjectURL(imageUrl);
+    }
+  
+    // 8. 返回 File 对象
+    return new File([outputBlob], filename, { type: 'image/png' });
+  };
+
+  /**
+   * 上传 File 到 S3,返回公开访问的 URL
+   */
+  const uploadFileToS3 = (file: File): Promise<string> => {
+    return new Promise((resolve, reject) => {
+      if (typeof window === 'undefined' || !window.AWS) {
+        reject(new Error('AWS SDK not available'));
+        return;
+      }
+
+      const credentials = {
+        accessKeyId: 'AKIATLPEDU37QV5CHLMH',
+        secretAccessKey: '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',
+      };
+      window.AWS.config.update(credentials);
+      window.AWS.config.region = 'cn-northwest-1';
+
+      const bucket = new window.AWS.S3({ params: { Bucket: 'ccrb' } });
+      const ext = file.name.split('.').pop() || 'bin';
+      const key = `${file.name.split('.')[0]}_${Date.now()}.${ext}`;
+
+      const params = {
+        Key: "pptto/"+key,
+        ContentType: file.type,
+        Body: file,
+        ACL: 'public-read',
+      };
+      const options = {
+        partSize: 2048 * 1024 * 1024, // 2GB 分片,可酌情调小
+        queueSize: 2,
+        leavePartsOnError: true,
+      };
+
+      bucket
+        .upload(params, options)
+        .promise()
+        .then(data => resolve(data.Location))
+        .catch(err => reject(err));
+    });
+  };
+
+  /*
   // 导入PPTX文件
   const importPPTXFile = (files: FileList, options?: { cover?: boolean; fixedViewport?: boolean }) => {
     console.log('导入', files)
@@ -405,13 +558,15 @@ export default () => {
       try {
         json = await parse(e.target!.result as ArrayBuffer)
       }
-      catch {
+      catch (error) {
         exporting.value = false
+        console.log('导入PPTX文件失败:', error)
         message.error('无法正确读取 / 解析该文件')
         return
       }
 
-      let ratio = 96 / 72
+      let ratio = 96 / 72;
+      //let ratio = 1
       const width = json.size.width
       
       if (fixedViewport) ratio = 1000 / width
@@ -460,7 +615,9 @@ export default () => {
         }
 
         const parseElements = (elements: Element[]) => {
+          
           const sortedElements = elements.sort((a, b) => a.order - b.order)
+          console.log(sortedElements)
 
           for (const el of sortedElements) {
             const originWidth = el.width || 1
@@ -472,7 +629,7 @@ export default () => {
             el.height = el.height * ratio
             el.left = el.left * ratio
             el.top = el.top * ratio
-  
+
             if (el.type === 'text') {
               const textEl: PPTTextElement = {
                 type: 'text',
@@ -696,7 +853,7 @@ export default () => {
             else if (el.type === 'table') {
               const row = el.data.length
               const col = el.data[0].length
-  
+
               const style: TableCellStyle = {
                 fontname: theme.value.fontName,
                 color: theme.value.fontColor,
@@ -736,7 +893,7 @@ export default () => {
                 }
                 data.push(rowCells)
               }
-  
+
               const allWidth = el.colWidths.reduce((a, b) => a + b, 0)
               const colWidths: number[] = el.colWidths.map(item => item / allWidth)
 
@@ -752,7 +909,7 @@ export default () => {
               const borderWidth = border?.borderWidth || 0
               const borderStyle = border?.borderType || 'solid'
               const borderColor = border?.borderColor || '#eeece1'
-  
+
               slide.elements.push({
                 type: 'table',
                 id: nanoid(10),
@@ -775,7 +932,7 @@ export default () => {
               let labels: string[]
               let legends: string[]
               let series: number[][]
-  
+
               if (el.chartType === 'scatterChart' || el.chartType === 'bubbleChart') {
                 labels = el.data[0].map((item, index) => `坐标${index + 1}`)
                 legends = ['X', 'Y']
@@ -789,7 +946,7 @@ export default () => {
               }
 
               const options: ChartOptions = {}
-  
+
               let chartType: ChartType = 'bar'
 
               switch (el.chartType) {
@@ -825,7 +982,7 @@ export default () => {
                   break
                 default:
               }
-  
+
               slide.elements.push({
                 type: 'chart',
                 id: nanoid(10),
@@ -899,6 +1056,719 @@ export default () => {
     }
     reader.readAsArrayBuffer(file)
   }
+*/
+
+  const importPPTXFile = (files: FileList, options?: { cover?: boolean; fixedViewport?: boolean }) => {
+    console.log('导入', files);
+    const defaultOptions = {
+      cover: false,
+      fixedViewport: false,
+    };
+    const { cover, fixedViewport } = { ...defaultOptions, ...options };
+
+    const file = files[0];
+    if (!file) return;
+
+    exporting.value = true; // 假设 exporting 是一个全局 ref
+
+    // 预加载形状库(用于后续形状匹配)
+    const shapeList: ShapePoolItem[] = [];
+    for (const item of SHAPE_LIST) {
+      shapeList.push(...item.children);
+    }
+
+    const reader = new FileReader();
+    reader.onload = async e => {
+      let json = null;
+      try {
+        json = await parse(e.target!.result as ArrayBuffer);
+      } catch (error) {
+        exporting.value = false;
+        console.log('导入PPTX文件失败:', error);
+        message.error('无法正确读取 / 解析该文件');
+        return;
+      }
+
+      // 计算缩放比例
+      let ratio = 96 / 72; // PPTX 默认 72 DPI,屏幕 96 DPI
+      const width = json.size.width;
+      const height = json.size.height;
+      const viewportRatio = json.size.viewportRatio || (height && width ? height / width : undefined)
+      if (fixedViewport) {
+        ratio = 1000 / width; // 固定视口宽度为 1000px
+      } else {
+        slidesStore.setViewportSize(width * ratio); // 调整画布大小
+      }
+
+      // 设置主题色
+      slidesStore.setTheme({ themeColors: json.themeColors });
+
+      const slides: Slide[] = [];
+
+      // 收集当前幻灯片内所有上传任务
+      const uploadTasks: Promise<void>[] = [];
+
+      // 遍历每一张幻灯片
+      for (const item of json.slides) {
+        // ----- 解析背景 -----
+        const { type, value } = item.fill;
+        let background: SlideBackground;
+        if (type === 'image') {
+          // 背景图片也可能需要上传(但 PPTX 背景图通常是内嵌 base64)
+          // 这里为了简化,暂不处理背景图片上传,如有需要可类似元素上传
+          background = {
+            type: 'image',
+            image: {
+              src: value.picBase64,
+              size: 'cover',
+            },
+          };
+        } else if (type === 'gradient') {
+          background = {
+            type: 'gradient',
+            gradient: {
+              type: value.path === 'line' ? 'linear' : 'radial',
+              colors: value.colors.map(item => ({
+                ...item,
+                pos: parseInt(item.pos),
+              })),
+              rotate: value.rot + 90,
+            },
+          };
+        } else {
+          background = {
+            type: 'solid',
+            color: value || '#fff',
+          };
+        }
+
+        const slide: Slide = {
+          id: nanoid(10),
+          elements: [],
+          background,
+          remark: item.note || '',
+        };
+
+        // ----- 解析元素(递归函数)-----
+        const parseElements = async (elements: any[], pelements: any = null) => {
+          // 按绘制顺序排序
+          const sortedElements = elements.sort((a, b) => a.order - b.order);
+          console.log(sortedElements)
+
+          for (const el of sortedElements) {
+            // 保存原始尺寸用于后续可能的路径计算
+            const originWidth = el.width || 1;
+            const originHeight = el.height || 1;
+            const originLeft = el.left;
+            const originTop = el.top;
+            // 保存原始尺寸用于后续可能的路径计算
+            const poriginWidth = pelements?.width;
+            const poriginHeight = pelements?.height;
+            const poriginLeft = pelements?.left;
+            const poriginTop = pelements?.top;
+
+            // 应用缩放
+            el.width = el.width * ratio;
+            el.height = el.height * ratio;
+            el.left = el.left * ratio;
+            el.top = el.top * ratio;
+
+            if (el.type === 'text') {
+              const textEl: PPTTextElement = {
+                type: 'text',
+                id: nanoid(10),
+                width: el.width,
+                height: el.height,
+                left: el.left,
+                top: el.top,
+                rotate: el.rotate,
+                defaultFontName: theme.value.fontName,
+                defaultColor: theme.value.fontColor,
+                content: convertFontSizePtToPx(el.content, ratio),
+                style: getStyle(convertFontSizePtToPx(el.content, ratio)),
+                lineHeight: 1.5,
+                outline: {
+                  color: el.borderColor,
+                  width: +(el.borderWidth * ratio).toFixed(2),
+                  style: el.borderType,
+                },
+                fill: el.fill.type === 'color' ? el.fill.value : '',
+                vertical: el.isVertical,
+              }
+              if (el.shadow) {
+                textEl.shadow = {
+                  h: el.shadow.h * ratio,
+                  v: el.shadow.v * ratio,
+                  blur: el.shadow.blur * ratio,
+                  color: el.shadow.color,
+                }
+              }
+              slide.elements.push(textEl)
+            }
+            // ---------- 图片 ----------
+            if (el.type === 'image') {
+              const element: PPTImageElement = {
+                type: 'image',
+                id: nanoid(10),
+                src: el.src, // 可能是 base64 或已有 URL
+                width: el.width,
+                height: el.height,
+                left: el.left,
+                top: el.top,
+                fixedRatio: true,
+                rotate: el.rotate,
+                flipH: el.isFlipH,
+                flipV: el.isFlipV,
+              };
+
+              // 边框
+              if (el.borderWidth) {
+                element.outline = {
+                  color: el.borderColor,
+                  width: +(el.borderWidth * ratio).toFixed(2),
+                  style: el.borderType,
+                };
+              }
+
+              // 裁剪(形状剪裁)
+              const clipShapeTypes = ['roundRect', 'ellipse', 'triangle', 'rhombus', 'pentagon', 'hexagon', 'heptagon', 'octagon', 'parallelogram', 'trapezoid'];
+              if (el.rect) {
+                element.clip = {
+                  shape: (el.geom && clipShapeTypes.includes(el.geom)) ? el.geom : 'rect',
+                  range: [
+                    [el.rect.l || 0, el.rect.t || 0],
+                    [100 - (el.rect.r || 0), 100 - (el.rect.b || 0)],
+                  ],
+                };
+              } else if (el.geom && clipShapeTypes.includes(el.geom)) {
+                element.clip = {
+                  shape: el.geom,
+                  range: [[0, 0], [100, 100]],
+                };
+              }
+
+              // 如果 src 是 base64,触发上传
+              if (el.src && typeof el.src === 'string' && el.src.startsWith('data:')) {
+                const uploadTask = (async () => {
+                  try {
+                    const file = await makeWhiteTransparent(el.src, `image_${Date.now()}.png`);
+                    const url = await uploadFileToS3(file);
+                    element.src = url; // 替换为远程 URL
+                  } catch (error) {
+                    console.error('Image upload failed:', error);
+                    // 失败时保留原 base64(或可置空)
+                  }
+                })();
+                uploadTasks.push(uploadTask);
+              }
+
+              slide.elements.push(element);
+            }
+            else if (el.type === 'math') {
+              const element: PPTImageElement = {
+                type: 'image',
+                id: nanoid(10),
+                src: el.picBase64,
+                width: el.width,
+                height: el.height,
+                left: el.left,
+                top: el.top,
+                fixedRatio: true,
+                rotate: 0,
+              }
+              
+              // 如果 src 是 base64,触发上传
+              if (el.src && typeof el.src === 'string' && el.src.startsWith('data:')) {
+                const uploadTask = (async () => {
+                  try {
+                    const file = makeWhiteTransparent(el.src, `image_${Date.now()}.png`, 'image/png');
+                    const url = await uploadFileToS3(file);
+                    element.src = url; // 替换为远程 URL
+                  } catch (error) {
+                    console.error('Image upload failed:', error);
+                    // 失败时保留原 base64(或可置空)
+                  }
+                })();
+                uploadTasks.push(uploadTask);
+              }
+              
+
+              slide.elements.push(element)
+
+            }
+            // ---------- 音频 ----------
+            else if (el.type === 'audio') {
+              const element: PPTAudioElement = {
+                type: 'audio',
+                id: nanoid(10),
+                src: el.blob,
+                width: el.width,
+                height: el.height,
+                left: el.left,
+                top: el.top,
+                rotate: 0,
+                fixedRatio: false,
+                color: theme.value.themeColors[0],
+                loop: false,
+                autoplay: false,
+              };
+
+              if (el.blob instanceof Blob) {
+                const uploadTask = (async () => {
+                  try {
+                    const file = dataToFile(el.blob, `audio_${Date.now()}.mp3`, el.blob.type);
+                    const url = await uploadFileToS3(file);
+                    element.src = url;
+                  } catch (error) {
+                    console.error('Audio upload failed:', error);
+                  }
+                })();
+                uploadTasks.push(uploadTask);
+              }
+
+              slide.elements.push(element);
+            }
+
+            // ---------- 视频 ----------
+            else if (el.type === 'video') {
+              const element: PPTVideoElement = {
+                type: 'video',
+                id: nanoid(10),
+                src: (el.blob || el.src)!,
+                width: el.width,
+                height: el.height,
+                left: el.left,
+                top: el.top,
+                rotate: 0,
+                autoplay: false,
+              };
+
+              const localData = el.blob || (el.src && typeof el.src === 'string' && el.src.startsWith('data:') ? el.src : null);
+              if (localData) {
+                const uploadTask = (async () => {
+                  try {
+                    let file: File;
+                    if (localData instanceof Blob) {
+                      file = dataToFile(localData, `video_${Date.now()}.mp4`, localData.type);
+                    } else {
+                      file = dataToFile(localData, `video_${Date.now()}.mp4`, 'video/mp4');
+                    }
+                    const url = await uploadFileToS3(file);
+                    element.src = url;
+                  } catch (error) {
+                    console.error('Video upload failed:', error);
+                  }
+                })();
+                uploadTasks.push(uploadTask);
+              }
+
+              slide.elements.push(element);
+            }
+            
+
+            // ---------- 形状 ----------
+            else if (el.type === 'shape') {
+              if (el.shapType === 'line' || /Connector/.test(el.shapType)) {
+                // 线条元素(单独处理)
+                const lineElement = parseLineElement(el, ratio);
+                slide.elements.push(lineElement);
+              } else {
+                const shape = shapeList.find(item => item.pptxShapeType === el.shapType);
+
+                const vAlignMap: { [key: string]: ShapeTextAlign } = {
+                  mid: 'middle',
+                  down: 'bottom',
+                  up: 'top',
+                };
+
+                const gradient: Gradient | undefined = el.fill?.type === 'gradient'
+                  ? {
+                      type: el.fill.value.path === 'line' ? 'linear' : 'radial',
+                      colors: el.fill.value.colors.map(item => ({
+                        ...item,
+                        pos: parseInt(item.pos),
+                      })),
+                      rotate: el.fill.value.rot,
+                    }
+                  : undefined;
+
+                const pattern: string | undefined = el.fill?.type === 'image' ? el.fill.value.picBase64 : undefined;
+                const fill = el.fill?.type === 'color' ? el.fill.value : '';
+
+                const element: PPTShapeElement = {
+                  type: 'shape',
+                  id: nanoid(10),
+                  width: el.width,
+                  height: el.height,
+                  left: el.left,
+                  top: el.top,
+                  viewBox: [200, 200],
+                  path: 'M 0 0 L 200 0 L 200 200 L 0 200 Z',
+                  fill,
+                  gradient,
+                  pattern,
+                  fixedRatio: false,
+                  rotate: el.rotate,
+                  outline: {
+                    color: el.borderColor,
+                    width: +(el.borderWidth * ratio).toFixed(2),
+                    style: el.borderType,
+                  },
+                  text: {
+                    content: convertFontSizePtToPx(el.content, ratio),
+                    style: getStyle(convertFontSizePtToPx(el.content, ratio)),
+                    defaultFontName: theme.value.fontName,
+                    defaultColor: theme.value.fontColor,
+                    align: vAlignMap[el.vAlign] || 'middle',
+                  },
+                  flipH: el.isFlipH,
+                  flipV: el.isFlipV,
+                };
+
+                if (el.shadow) {
+                  element.shadow = {
+                    h: el.shadow.h * ratio,
+                    v: el.shadow.v * ratio,
+                    blur: el.shadow.blur * ratio,
+                    color: el.shadow.color,
+                  };
+                }
+
+                if (shape) {
+                  element.path = shape.path;
+                  //const { maxX, maxY } = getSvgPathRange(el.path);
+                  element.viewBox = shape.viewBox;
+                  //element.viewBox = [originWidth || maxX, originHeight || maxY];
+                  if (shape.pathFormula) {
+                    element.pathFormula = shape.pathFormula;
+                    element.viewBox = [el.width, el.height];
+                    //element.viewBox = [poriginWidth || originWidth || maxX, poriginHeight || originHeight || maxY];  
+                    const pathFormula = SHAPE_PATH_FORMULAS[shape.pathFormula];
+                    if ('editable' in pathFormula && pathFormula.editable) {
+                      element.path = pathFormula.formula(el.width, el.height, pathFormula.defaultValue);
+                      element.keypoints = pathFormula.defaultValue;
+                    } else {
+                      element.path = pathFormula.formula(el.width, el.height);
+                    }
+                  }
+                }
+                else if (el.path && el.path.indexOf('NaN') === -1) {
+                  const { maxX, maxY } = getSvgPathRange(el.path);
+                  element.path = el.path;
+                  element.viewBox = poriginWidth? [maxX, maxY]: [originWidth, originHeight];
+                  //element.viewBox = [originWidth || maxX, originHeight || maxY];  
+                  //element.viewBox = originWidth? [(originWidth/(poriginWidth||1)), (originHeight/(poriginHeight||1))] : [maxX, maxY];
+                  //element.viewBox = [poriginWidth || maxX, poriginHeight || maxY];
+                }
+
+                if (el.shapType === 'custom') {
+                  if (el.path!.indexOf('NaN') !== -1) {
+                    if (element.width === 0) element.width = 0.1;
+                    if (element.height === 0) element.height = 0.1;
+                    element.path = el.path!.replace(/NaN/g, '0');
+                  } else {
+                    element.special = true;
+                    element.path = el.path!;
+                  }
+                  const { maxX, maxY } = getSvgPathRange(element.path);
+                  element.viewBox = [maxX, maxY];
+                  //element.viewBox = [originWidth || maxX, originHeight || maxY];  
+                  //element.viewBox = [poriginWidth || originWidth || maxX, poriginHeight || originHeight || maxY];  
+                  //element.viewBox = [poriginWidth || originWidth || maxX, poriginHeight || originHeight || maxY];  
+                  //element.viewBox = [Math.max(maxX, originWidth), Math.max(maxY, originHeight)];
+                  //element.viewBox = [originWidth, originHeight];
+                }
+
+                if (element.path) slide.elements.push(element);
+              }
+            }
+
+            // ---------- 表格 ----------
+            else if (el.type === 'table') {
+              const row = el.data.length;
+              const col = el.data[0].length;
+
+              const style: TableCellStyle = {
+                fontname: theme.value.fontName,
+                color: theme.value.fontColor,
+              };
+              const data: TableCell[][] = [];
+
+              for (let i = 0; i < row; i++) {
+                const rowCells: TableCell[] = [];
+                for (let j = 0; j < col; j++) {
+                  const cellData = el.data[i][j];
+
+                  let textDiv: HTMLDivElement | null = document.createElement('div');
+                  textDiv.innerHTML = cellData.text;
+                  const p = textDiv.querySelector('p');
+                  const align = p?.style.textAlign || 'left';
+
+                  const span = textDiv.querySelector('span');
+                  const fontsize = span?.style.fontSize
+                    ? (parseInt(span?.style.fontSize) * ratio).toFixed(1) + 'px'
+                    : '';
+                  const fontname = span?.style.fontFamily || '';
+                  const color = span?.style.color || cellData.fontColor;
+
+                  rowCells.push({
+                    id: nanoid(10),
+                    colspan: cellData.colSpan || 1,
+                    rowspan: cellData.rowSpan || 1,
+                    text: textDiv.innerText,
+                    style: {
+                      ...style,
+                      align: ['left', 'right', 'center'].includes(align) ? (align as 'left' | 'right' | 'center') : 'left',
+                      fontsize,
+                      fontname,
+                      color,
+                      bold: cellData.fontBold,
+                      backcolor: cellData.fillColor,
+                    },
+                  });
+                  textDiv = null;
+                }
+                data.push(rowCells);
+              }
+
+              const allWidth = el.colWidths.reduce((a, b) => a + b, 0);
+              const colWidths: number[] = el.colWidths.map(item => item / allWidth);
+
+              const firstCell = el.data[0][0];
+              const border = firstCell.borders.top ||
+                firstCell.borders.bottom ||
+                el.borders.top ||
+                el.borders.bottom ||
+                firstCell.borders.left ||
+                firstCell.borders.right ||
+                el.borders.left ||
+                el.borders.right;
+              const borderWidth = border?.borderWidth || 0;
+              const borderStyle = border?.borderType || 'solid';
+              const borderColor = border?.borderColor || '#eeece1';
+
+              slide.elements.push({
+                type: 'table',
+                id: nanoid(10),
+                width: el.width,
+                height: el.height,
+                left: el.left,
+                top: el.top,
+                colWidths,
+                rotate: 0,
+                data,
+                outline: {
+                  width: +(borderWidth * ratio || 2).toFixed(2),
+                  style: borderStyle,
+                  color: borderColor,
+                },
+                cellMinHeight: el.rowHeights[0] ? el.rowHeights[0] * ratio : 36,
+              });
+            }
+
+            // ---------- 图表 ----------
+            else if (el.type === 'chart') {
+              let labels: string[];
+              let legends: string[];
+              let series: number[][];
+
+              if (el.chartType === 'scatterChart' || el.chartType === 'bubbleChart') {
+                labels = el.data[0].map((_, index) => `坐标${index + 1}`);
+                legends = ['X', 'Y'];
+                series = el.data;
+              } else {
+                const data = el.data as ChartItem[];
+                labels = Object.values(data[0].xlabels);
+                legends = data.map(item => item.key);
+                series = data.map(item => item.values.map(v => v.y));
+              }
+
+              const options: ChartOptions = {};
+
+              let chartType: ChartType = 'bar';
+              switch (el.chartType) {
+                case 'barChart':
+                case 'bar3DChart':
+                  chartType = 'bar';
+                  if (el.barDir === 'bar') chartType = 'column';
+                  if (el.grouping === 'stacked' || el.grouping === 'percentStacked') options.stack = true;
+                  break;
+                case 'lineChart':
+                case 'line3DChart':
+                  if (el.grouping === 'stacked' || el.grouping === 'percentStacked') options.stack = true;
+                  chartType = 'line';
+                  break;
+                case 'areaChart':
+                case 'area3DChart':
+                  if (el.grouping === 'stacked' || el.grouping === 'percentStacked') options.stack = true;
+                  chartType = 'area';
+                  break;
+                case 'scatterChart':
+                case 'bubbleChart':
+                  chartType = 'scatter';
+                  break;
+                case 'pieChart':
+                case 'pie3DChart':
+                  chartType = 'pie';
+                  break;
+                case 'radarChart':
+                  chartType = 'radar';
+                  break;
+                case 'doughnutChart':
+                  chartType = 'ring';
+                  break;
+                default:
+              }
+
+              slide.elements.push({
+                type: 'chart',
+                id: nanoid(10),
+                chartType,
+                width: el.width,
+                height: el.height,
+                left: el.left,
+                top: el.top,
+                rotate: 0,
+                themeColors: el.colors.length ? el.colors : theme.value.themeColors,
+                textColor: theme.value.fontColor,
+                data: {
+                  labels,
+                  legends,
+                  series,
+                },
+                options,
+              });
+            }
+
+            // ---------- 组合 ----------
+            else if (el.type === 'group') {
+              // 先将子元素坐标转换到画布绝对坐标
+              let elements: BaseElement[] = el.elements.map((_el: any) => {
+                let left = _el.left + originLeft;
+                let top = _el.top + originTop;
+
+                if (el.rotate) {
+                  const { x, y } = calculateRotatedPosition(
+                    originLeft, originTop, originWidth, originHeight,
+                    _el.left, _el.top, el.rotate
+                  );
+                  left = x;
+                  top = y;
+                }
+
+                const element = {
+                  ..._el,
+                  left,
+                  top,
+                };
+                if (el.isFlipH && 'isFlipH' in element) element.isFlipH = true;
+                if (el.isFlipV && 'isFlipV' in element) element.isFlipV = true;
+
+                return element;
+              });
+
+              if (el.isFlipH) elements = flipGroupElements(elements, 'y');
+              if (el.isFlipV) elements = flipGroupElements(elements, 'x');
+
+              // 递归解析子元素(注意:子元素的上传任务会加入同一个 uploadTasks 数组)
+              await parseElements(elements, el);
+            }
+
+            // ---------- 图表组合(SmartArt)----------
+            else if (el.type === 'diagram') {
+              const elements = el.elements.map((_el: any) => ({
+                ..._el,
+                left: _el.left + originLeft,
+                top: _el.top + originTop,
+              }));
+              await parseElements(elements, el);
+            }
+          }
+        };
+
+        // 开始解析当前幻灯片的所有元素(包括布局元素)
+        await parseElements([...item.elements, ...item.layoutElements]);
+
+        // 幻灯片构建完成,加入数组
+        slides.push(slide);
+      }
+
+      // 根据选项将幻灯片插入 store
+      if (cover) {
+        slidesStore.updateSlideIndex(0);
+        slidesStore.setSlides(slides);
+        addHistorySnapshot();
+      } else if (isEmptySlide.value) {
+        slidesStore.setSlides(slides);
+        addHistorySnapshot();
+      } else {
+        addSlidesFromData(slides);
+      }
+
+      // 等待当前幻灯片内所有上传任务完成
+      Promise.all(uploadTasks);
+
+      exporting.value = false;
+
+      /*
+      // 更新视口尺寸(如果提供了的话)
+      if (width !== undefined && height !== undefined) {
+        console.log('正在触发视口尺寸更新事件:', { width, height, viewportRatio })
+        
+        // 同时也要更新slidesStore中的相关数据
+        if (slidesStore.setViewportSize) {
+          console.log('正在更新store中的视口尺寸')
+          slidesStore.setViewportSize(width)
+          if (slidesStore.setViewportRatio && viewportRatio !== undefined) {
+            slidesStore.setViewportRatio(viewportRatio)
+            console.log('视口比例已更新:', viewportRatio)
+          }
+        }
+        
+        window.dispatchEvent(new CustomEvent('viewportSizeUpdated', { 
+          detail: { width, height, viewportRatio }
+        }))
+        console.log('视口尺寸更新事件已触发')
+      }
+      
+      // 导入成功后,触发画布尺寸更新
+      // 使用 nextTick 确保DOM更新完成后再触发
+      console.log('开始触发画布尺寸更新事件...')
+      nextTick(() => {
+        console.log('DOM更新完成,触发 slidesDataUpdated 事件')
+        // 触发自定义事件,通知需要更新画布尺寸的组件
+        window.dispatchEvent(new CustomEvent('slidesDataUpdated', { 
+          detail: { 
+            slides, 
+            cover,
+            title,
+            theme,
+            width,
+            height,
+            viewportRatio,
+            timestamp: Date.now()
+          } 
+        }))
+        console.log('slidesDataUpdated 事件已触发')
+        
+        // 检查并调整幻灯片索引,确保在有效范围内
+        const newSlideCount = slides.length
+        const currentIndex = slidesStore.slideIndex
+        if (currentIndex >= newSlideCount) {
+          console.log('调整幻灯片索引:', currentIndex, '->', Math.max(0, newSlideCount - 1))
+          slidesStore.updateSlideIndex(Math.max(0, newSlideCount - 1))
+        }
+        
+        console.log('画布尺寸更新事件处理完成')
+        
+
+      })
+*/
+
+    };
+
+    reader.readAsArrayBuffer(file);
+  };
 
   const getFile = (url: string): Promise<{ data: any }> => {
     return new Promise((resolve, reject) => {
@@ -986,6 +1856,8 @@ export default () => {
     exportJSON2,
     exporting,
     getFile,
-    getFile2
+    getFile2,
+    dataToFile,
+    uploadFileToS3
   }
 }

+ 19 - 2
src/main.ts

@@ -1,6 +1,23 @@
 import { createApp } from 'vue'
 import { createPinia } from 'pinia'
 import App from './App.vue'
+import en from './views/lang/en.json'
+import cn from './views/lang/cn.json'
+import hk from './views/lang/hk.json'
+
+export let lang = cn
+if (window.location.href.includes('cocorobo.cn')) {
+  lang = cn
+}
+else if (window.location.href.includes('cocorobo.hk')) {
+  lang = hk
+}
+else if (window.location.href.includes('cocorobo.com')) {
+  lang = en
+}
+else {
+  lang = cn
+}
 
 // TypeScript declarations for global properties
 declare module '@vue/runtime-core' {
@@ -29,9 +46,9 @@ export const getCurrentVersion = () => {
 }
 
 // 当前版本
-const currentVersion = getCurrentVersion()
+export const currentVersion = getCurrentVersion()
 
-export default currentVersion
+export default {currentVersion, lang}
 
 const app = createApp(App)
 // 注册全局变量

+ 28 - 4
src/services/config.ts

@@ -79,11 +79,35 @@ instance.interceptors.response.use(
     if (response.status >= 200 && response.status < 400) {
       return Promise.resolve(response.data)
     }
-
+    message.error(response.config.url || '')
     message.error('未知的请求错误!')
     return Promise.reject(response)
   },
   (error) => {
+    const config = error.config
+    let fullUrl = '未知请求'
+  
+    if (config) {
+      // 拼接 baseURL 和 url
+      const baseURL = config.baseURL || ''
+      const url = config.url || ''
+      fullUrl = baseURL + url
+  
+      // 如果有查询参数,添加到 URL 中
+      if (config.params) {
+        const params = new URLSearchParams(config.params).toString()
+        if (params) {
+          fullUrl += '?' + params
+        }
+      }
+
+      // 检查是否需要显示错误信息
+      const showError = config.showError !== false
+      if (!showError) {
+        return Promise.reject(error)
+      }
+    }
+
     if (error && error.response) {
       if (error.response.status >= 400 && error.response.status < 500) {
         return Promise.reject(error.message)
@@ -91,12 +115,12 @@ instance.interceptors.response.use(
       else if (error.response.status >= 500) {
         return Promise.reject(error.message)
       }
-
+      
       message.error('服务器遇到未知错误!')
       return Promise.reject(error.message)
     }
-
-    message.error('连接到服务器失败 或 服务器响应超时!')
+    message.error(fullUrl)
+    message.error(error)
     return Promise.reject(error)
   }
 )

+ 2 - 2
src/services/course.ts

@@ -83,8 +83,8 @@ export const selectWorksStudent = (oid: string, cid: string): Promise<any> => {
  * @param url 目标URL
  * @returns Promise<any>
  */
-export const getHTML = (url: string): Promise<any> => {
-  return axios.get(`${url}`)
+export const getHTML = (url: string, showError: boolean = false): Promise<any> => {
+  return axios.get(`${url}`, { showError })
 }
 
 /**

+ 1 - 1
src/store/main.ts

@@ -62,7 +62,7 @@ export const useMainStore = defineStore('main', {
     clipingImageElementId: '', // 当前正在裁剪的图片ID  
     richTextAttrs: defaultRichTextAttrs, // 富文本状态
     selectedTableCells: [], // 选中的表格单元格
-    isScaling: false, // 正在进行元素缩放
+    isScaling: true, // 正在进行元素缩放
     selectedSlidesIndex: [], // 当前被选中的页面索引集合
     dialogForExport: '', // 导出面板
     databaseId, // 标识当前应用的indexedDB数据库ID

+ 1 - 0
src/store/slides.ts

@@ -207,6 +207,7 @@ export const useSlidesStore = defineStore('slides', {
     },
   
     updateElement(data: UpdateElementData) {
+      console.log('data', data)
       const { id, props, slideId } = data
       const elIdList = typeof id === 'string' ? [id] : id
 

+ 2 - 0
src/types/slides.ts

@@ -185,6 +185,7 @@ export interface PPTTextElement extends PPTBaseElement {
   paragraphSpace?: number
   vertical?: boolean
   textType?: TextType
+  style?: string
 }
 
 
@@ -311,6 +312,7 @@ export interface ShapeText {
   defaultColor: string
   align: ShapeTextAlign
   type?: TextType
+  style?: string
 }
 
 /**

+ 1 - 1
src/utils/prosemirror/schema/marks.ts

@@ -10,7 +10,7 @@ const subscript: MarkSpec = {
       getAttrs: value => value === 'sub' && null
     },
   ],
-  toDOM: () => ['sub', 0],
+  toDOM: () => ['sub',  0],
 }
 
 const superscript: MarkSpec = {

+ 1 - 1
src/views/Editor/Canvas/hooks/useScaleElement.ts

@@ -454,7 +454,7 @@ export default (
       if (startPageX === currentPageX && startPageY === currentPageY) return
       
       slidesStore.updateSlide({ elements: elementList.value })
-      mainStore.setScalingState(false)
+      mainStore.setScalingState(true)
       
       addHistorySnapshot()
     }

+ 45 - 3
src/views/Editor/CanvasTool/index.vue

@@ -71,7 +71,7 @@
         <IconInsertTable class="handler-item" v-tooltip="'插入表格'" />
       </Popover>
       <IconFormula class="handler-item" v-tooltip="'插入公式'" @click="latexEditorVisible = true" />
-      <Popover trigger="manual" v-model:value="webpageInputVisible" :offset="10">
+      <Popover v-if="viewMode !== 'editor2'" trigger="manual" v-model:value="webpageInputVisible" :offset="10">
         <template #content>
           <WebpageInput 
             :webpageList="webpageList"
@@ -94,6 +94,7 @@
     </div>
 
     <div class="right-handler">
+      <div v-if="hasInteractiveTool" class="handler-item viewport-size edit-tool-btn" @click="editTool">编辑工具</div>
       <IconMinus class="handler-item viewport-size" v-tooltip="'画布缩小(Ctrl + -)'" @click="scaleCanvas('-')" />
       <Popover trigger="click" v-model:value="canvasScaleVisible">
         <template #content>
@@ -124,9 +125,9 @@
 </template>
 
 <script lang="ts" setup>
-import { ref } from 'vue'
+import { ref, computed } from 'vue'
 import { storeToRefs } from 'pinia'
-import { useMainStore, useSnapshotStore } from '@/store'
+import { useMainStore, useSnapshotStore, useSlidesStore } from '@/store'
 import { getImageDataURL } from '@/utils/image'
 import type { ShapePoolItem } from '@/configs/shapes'
 import type { LinePoolItem } from '@/configs/lines'
@@ -148,8 +149,44 @@ import Popover from '@/components/Popover.vue'
 import PopoverMenuItem from '@/components/PopoverMenuItem.vue'
 
 const mainStore = useMainStore()
+const slidesStore = useSlidesStore()
 const { creatingElement, creatingCustomShape, showSelectPanel, showSearchPanel, showNotesPanel } = storeToRefs(mainStore)
 const { canUndo, canRedo } = storeToRefs(useSnapshotStore())
+const { currentSlide } = storeToRefs(slidesStore)
+
+const getInitialViewMode = () => {
+  const urlParams = new URLSearchParams(window.location.search)
+  const modeFromUrl = urlParams.get('mode')
+  if (modeFromUrl === 'editor2') {
+    return 'editor2'
+  }
+  const modeFromStorage = localStorage.getItem('viewMode')
+  if (modeFromStorage) {
+    return modeFromStorage
+  }
+  return 'editor'
+}
+
+const viewMode = computed(() => getInitialViewMode())
+
+const hasInteractiveTool = computed(() => {
+  const elements = currentSlide.value?.elements || []
+  return elements.some((el: any) => el.type === 'frame' && (el.toolType === 45 || el.toolType === 15))
+})
+
+const editTool = () => {
+  const elements = currentSlide.value?.elements || []
+  const frameElement = elements.find((el: any) => el.type === 'frame' && (el.toolType === 45 || el.toolType === 15))
+  if (frameElement) {
+    const url = frameElement.url || ''
+    
+    interface ParentWindowWithToolList extends Window {
+      toolBtn?: (action: number, id: string) => void;
+    }
+    const parentWindow = window.parent as ParentWindowWithToolList
+    parentWindow?.toolBtn?.(0, url)
+  }
+}
 
 const { redo, undo } = useHistorySnapshot()
 
@@ -394,6 +431,11 @@ const toggleNotesPanel = () => {
   }
 }
 
+.edit-tool-btn{
+  color: #285cf5;
+  cursor: pointer;
+}
+
 @media screen and (width <= 1200px) {
   .right-handler .text {
     display: none;

+ 11 - 1
src/views/Editor/EditorHeader/index.vue

@@ -29,8 +29,13 @@
           <!-- <PopoverMenuItem @click="goLink('https://github.com/pipipi-pikachu/PPTist/blob/master/doc/Q&A.md')">常见问题</PopoverMenuItem> -->
           <PopoverMenuItem @click="mainMenuVisible = false; hotkeyDrawerVisible = true">快捷操作</PopoverMenuItem>
         </template>
-        <div class="menu-item"><IconHamburgerButton class="icon" /></div>
+        <div class="menu-item"  v-show="false"><IconHamburgerButton class="icon" /></div>
       </Popover>
+      <FileInput accept="application/vnd.openxmlformats-officedocument.presentationml.presentation" @change="files => {
+        importPPTXFile(files)
+      }">
+        <div class="menu-item"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>上传 PPTX 文件</div>
+      </FileInput>
 
       <div class="title" v-show="false">
         <Input 
@@ -204,6 +209,11 @@ const setTitle = (newTitle: string) => {
   &:hover {
     background-color: #f1f1f1;
   }
+
+  svg {
+    width: 18px;
+    margin-right: 5px;
+  }
 }
 .group-menu-item {
   height: 30px;

+ 2 - 2
src/views/Editor/Thumbnails/index.vue

@@ -7,7 +7,7 @@
   >
     <div class="add-slide">
       <div class="btn" @click="createSlide()"><IconPlus class="icon" />添加幻灯片</div>
-      <!-- <Popover trigger="click" placement="bottom-start" v-model:value="presetLayoutPopoverVisible" center>
+      <Popover trigger="click" placement="bottom-start" v-model:value="presetLayoutPopoverVisible" center>
         <template #content>
           <Templates 
             @select="slide => { createSlideByTemplate(slide); presetLayoutPopoverVisible = false }"
@@ -15,7 +15,7 @@
           />
         </template>
         <div class="select-btn"><IconDown /></div>
-      </Popover> -->
+      </Popover>
     </div>
 
     <Draggable 

+ 183 - 0
src/views/Editor/index3.vue

@@ -0,0 +1,183 @@
+<template>
+  <div class="pptist-editor">
+    <EditorHeader class="layout-header" />
+    <div class="layout-content">
+      <CollapsibleToolbar class="layout-sidebar" @toggle="handleToolbarToggle" />
+      <div class="layout-content-center">
+        <CanvasTool class="center-top" />
+        <Canvas class="center-body" :style="{ height: `calc(100% - ${remarkHeight + 40}px  - 120px)` }" :courseid="props.courseid"/>
+        <!-- <Remark
+          class="center-bottom" 
+          v-model:height="remarkHeight" 
+          :style="{ height: `${remarkHeight}px` }"
+           v-show="false"
+        /> -->
+        <Thumbnails class="layout-content-left" />
+      </div>
+      <Toolbar class="layout-content-right" v-show="false"/>
+    </div>
+  </div>
+
+  <SelectPanel v-if="showSelectPanel" />
+  <SearchPanel v-if="showSearchPanel" />
+  <NotesPanel v-if="showNotesPanel" />
+  <MarkupPanel v-if="showMarkupPanel" />
+
+  <Modal
+    :visible="!!dialogForExport" 
+    :width="680"
+    @closed="closeExportDialog()"
+  >
+    <ExportDialog />
+  </Modal>
+
+  <Modal
+    :visible="showAIPPTDialog" 
+    :width="720"
+    :closeOnClickMask="false"
+    :closeOnEsc="false"
+    closeButton
+    @closed="closeAIPPTDialog()"
+  >
+    <AIPPTDialog />
+  </Modal>
+
+  <Modal
+    class="createCourseDialog"
+    :visible="showCreateCourseDialog" 
+    :closeOnClickMask="false"
+    :closeOnEsc="false"
+    :closeButton="false"
+    @closed="closeCreateCourseDialog()"
+  >
+    <CreateCourseDialog @close="closeCreateCourseDialog" @select="handleCreateCourseSelect" />
+  </Modal>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, onMounted } 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/index2.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 MarkupPanel from './MarkupPanel.vue'
+import AIPPTDialog from './AIPPTDialog.vue'
+import Modal from '@/components/Modal.vue'
+import CollapsibleToolbar from '@/components/CollapsibleToolbar/index.vue'
+import CreateCourseDialog from '@/components/CreateCourseDialog.vue'
+
+interface Props {
+  courseid?: string | null
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  courseid: null,
+})
+
+const mainStore = useMainStore()
+const { dialogForExport, showSelectPanel, showSearchPanel, showNotesPanel, showMarkupPanel, showAIPPTDialog } = storeToRefs(mainStore)
+const closeExportDialog = () => mainStore.setDialogForExport('')
+const closeAIPPTDialog = () => mainStore.setAIPPTDialogState(false)
+
+const remarkHeight = ref(0)
+const sidebarCollapsed = ref(false)
+const showCreateCourseDialog = ref(false)
+
+const handleToolbarToggle = (collapsed: boolean) => {
+  sidebarCollapsed.value = collapsed
+}
+
+const closeCreateCourseDialog = () => {
+  showCreateCourseDialog.value = false
+}
+
+const handleCreateCourseSelect = (option: string) => {
+  console.log('Selected option:', option)
+  // 这里可以添加不同选项的处理逻辑
+  if (option === 'upload') {
+    // 触发文件上传
+    const fileInput = document.createElement('input')
+    fileInput.type = 'file'
+    fileInput.accept = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
+    fileInput.click()
+  }
+}
+
+onMounted(() => {
+  if (!props.courseid) {
+    showCreateCourseDialog.value = true
+  }
+})
+
+useGlobalHotkey()
+usePasteEvent()
+</script>
+
+<style lang="scss" scoped>
+.pptist-editor {
+  height: 100%;
+}
+.layout-header {
+  height: 40px;
+}
+.layout-content {
+  height: calc(100% - 40px);
+  display: flex;
+}
+.layout-sidebar {
+  // width: 200px;
+  width: auto;
+  height: 100%;
+  flex-shrink: 0;
+  transition: width 0.3s ease;
+}
+
+.layout-sidebar.collapsed {
+  width: 48px;
+}
+
+.layout-content-left {
+  width: 100%;
+  height: 120px;
+  flex-shrink: 0;
+}
+
+.layout-content-center {
+  flex: 1;
+  transition: width 0.3s ease;
+  max-width: 100%;
+  overflow: hidden;
+}
+
+.layout-content-center .center-top {
+  height: 40px;
+}
+
+.layout-content-right {
+  width: 260px;
+  height: 100%;
+}
+
+</style>
+<style lang="scss">
+.createCourseDialog{
+  background: #78797b;
+
+  .modal-content{
+    width: auto !important;
+    min-width: 800px;
+    border-radius: 10px;
+  }
+}
+</style>

+ 6 - 0
src/views/Student/components/AIWorkModal.vue

@@ -116,6 +116,7 @@ const processContent = async (content: string) => {
           // 如果已经包含html标签则不再渲染
           if (!/^(\s*<[^>]+>.*<\/[^>]+>\s*|<[^>]+\/>\s*)$/s.test(item2.content.trim())) {
             item2.content = md.render(item2.content)
+            item2.content = item2.content.replace(/&lt;/g, '<').replace(/&quot;/g, '"').replace(/&gt;/g, '>')
           }
         })
       }
@@ -202,6 +203,11 @@ watch(
 	background-color: #fff;
 }
 
+
+.na_m_i_content :deep(img){
+  max-width: 100%;
+}
+
 .messageNode{
   width: 100%;
   height: auto;

+ 13 - 0
src/views/Student/components/choiceQuestionDetailDialog.vue

@@ -414,9 +414,19 @@ const lookWorkData = computed(() => {
     const _workFind = processedWorkArray.value.find(
       (i: any) => i.id === lookWorkDetail.value
     )
+
+    _workFind.content.forEach((i:any) => {
+      i.messages.forEach((i2:any) => {
+        // 转字符  例如&lt; 》 <   &quot; 》 "  &gt; 》 >
+        i2.content = i2.content.replace(/&lt;/g, '<').replace(/&quot;/g, '"').replace(/&gt;/g, '>')
+      })
+    })
     if (_workFind) {
       _result = _workFind
     }
+
+
+    console.log(_workFind)
   }
 
   return _result
@@ -1153,6 +1163,9 @@ onUnmounted(() => {
             box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.1);
             border-radius: 0 0 12px 12px;
             background-color: #fff;
+            :deep(img){
+              max-width: 100%;
+            }
           }
 
           .messageNode {

+ 19 - 18
src/views/Student/index.vue

@@ -372,7 +372,7 @@ import useImport from '@/hooks/useImport'
 import message from '@/utils/message'
 import api, { API_URL } from '@/services/course'
 import axios from '@/services/config'
-import currentVersion from '@/main'
+import {currentVersion, lang} from '@/main'
 import ShotWorkModal from './components/ShotWorkModal.vue'
 import QAWorkModal from './components/QAWorkModal.vue'
 import ChoiceWorkModal from './components/ChoiceWorkModal.vue'
@@ -1503,16 +1503,28 @@ const processIframeLinks = async () => {
                   // 如果无法获取contentWindow,使用HTML方式
                   let html = null
                   try {
-                    const fileData = await getFile(iframeSrc)
-                    if (fileData && fileData.data) {
-                      const uint8Array = new Uint8Array(fileData.data)
-                      html = new TextDecoder('utf-8').decode(uint8Array)
-                      console.log('getFile 成功获取内容:', html)
+                    console.log(`getFile2 失败,尝试使用 getHTML:`, error2)
+                    try {
+                      html = await api.getHTML(iframeSrc)
+                      console.log('getHTML 成功获取内容:', html)
+                    }
+                    catch (htmlError) {
+                      console.error('getHTML 也失败:', htmlError)
+                      console.error('无法获取内容: getFile、getFile2 和 getHTML 都失败了')
+                      // throw new Error(`无法获取内容: getFile、getFile2 和 getHTML 都失败了`)
                     }
                   }
                   catch (error) {
                     console.log(`getFile 失败,尝试使用 getFile2:`, error)
                     try {
+                      const fileData = await getFile(iframeSrc)
+                      if (fileData && fileData.data) {
+                        const uint8Array = new Uint8Array(fileData.data)
+                        html = new TextDecoder('utf-8').decode(uint8Array)
+                        console.log('getFile 成功获取内容:', html)
+                      }
+                    }
+                    catch (error2) {
                       const fileData2 = await getFile2(iframeSrc)
                       if (fileData2 && fileData2.data) {
                         const uint8Array = new Uint8Array(fileData2.data)
@@ -1520,18 +1532,6 @@ const processIframeLinks = async () => {
                         console.log('getFile2 成功获取内容:', html)
                       }
                     }
-                    catch (error2) {
-                      console.log(`getFile2 失败,尝试使用 getHTML:`, error2)
-                      try {
-                        html = await api.getHTML(iframeSrc)
-                        console.log('getHTML 成功获取内容:', html)
-                      }
-                      catch (htmlError) {
-                        console.error('getHTML 也失败:', htmlError)
-                        console.error('无法获取内容: getFile、getFile2 和 getHTML 都失败了')
-                        // throw new Error(`无法获取内容: getFile、getFile2 和 getHTML 都失败了`)
-                      }
-                    }
                   }
                   console.log(`处理幻灯片 ${slideIndex + 1} 中的iframe链接:`, iframeSrc)
                   return {
@@ -3278,6 +3278,7 @@ const addOp3 = async (userTime: any, loadTime: any, object: any, status: any) =>
 }
 
 onMounted(() => {
+  
   document.addEventListener('keydown', handleKeydown)
 
   // 处理URL参数

+ 12 - 0
src/views/components/element/FrameElement/index.vue

@@ -30,6 +30,7 @@
         <!-- B站视频类型(type 75):使用 iframe -->
         <iframe 
           v-else-if="elementInfo.toolType === 75"
+          :key="'bilibili-' + iframeKey"
           :src="elementInfo.url"
           :width="elementInfo.width"
           :height="elementInfo.height"
@@ -40,6 +41,7 @@
         <!-- 其他类型:保持原有逻辑 -->
         <iframe 
           v-else-if="elementInfo.isHTML"
+          :key="'html-' + iframeKey"
           :srcdoc="elementInfo.url"
           :width="elementInfo.width"
           :height="elementInfo.height"
@@ -49,6 +51,7 @@
         ></iframe>
         <iframe 
           v-else
+          :key="'src-' + iframeKey"
           :src="elementInfo.url"
           :width="elementInfo.width"
           :height="elementInfo.height"
@@ -78,6 +81,7 @@ import { storeToRefs } from 'pinia'
 import { useMainStore } from '@/store'
 import type { PPTFrameElement } from '@/types/slides'
 import type { ContextmenuItem } from '@/components/Contextmenu/types'
+import { ref, watch } from 'vue'
 
 const props = defineProps({
   elementInfo: {
@@ -96,6 +100,14 @@ const props = defineProps({
 
 const { handleElementId } = storeToRefs(useMainStore())
 
+const iframeKey = ref(0)
+
+watch(() => props.elementInfo.url, (newUrl, oldUrl) => {
+  if (newUrl !== oldUrl) {
+    iframeKey.value++
+  }
+})
+
 const handleSelectElement = (e: MouseEvent | TouchEvent, canMove = true) => {
   e.stopPropagation()
   props.selectElement(e, props.elementInfo, canMove)

+ 1 - 2
src/views/components/element/ShapeElement/BaseShapeElement.vue

@@ -138,8 +138,7 @@ const text = computed<ShapeText>(() => {
   right: 0;
   display: flex;
   flex-direction: column;
-  padding: 10px;
-  line-height: 1.2;
+  padding: 5px;
   word-break: break-word;
 
   &.top {

+ 21 - 15
src/views/components/element/ShapeElement/index.vue

@@ -67,19 +67,20 @@
           </g>
         </svg>
 
-        <div class="shape-text" :class="[text.align, { 'editable': editable || text.content }]">
-          <ProsemirrorEditor
-            ref="prosemirrorEditorRef"
-            v-if="editable || text.content"
-            :elementId="elementInfo.id"
-            :defaultColor="text.defaultColor"
-            :defaultFontName="text.defaultFontName"
-            :editable="!elementInfo.lock"
-            :value="text.content"
-            @update="({ value, ignore }) => updateText(value, ignore)"
-            @blur="checkEmptyText()"
-            @mousedown="$event => handleSelectElement($event, false)"
-          />
+        <div class="shape-text" :style="text.style" :class="[text.align, { 'editable': editable || text.content }]">
+            <ProsemirrorEditor
+              ref="prosemirrorEditorRef"
+              v-if="editable || text.content"
+              :elementId="elementInfo.id"
+              :defaultColor="text.defaultColor"
+              :defaultFontName="text.defaultFontName"
+              :editable="!elementInfo.lock"
+              :value="text.content"
+              @update="({ value, ignore }) => updateText(value, ignore)"
+              @blur="checkEmptyText()"
+              @mousedown="$event => handleSelectElement($event, false)"
+            />
+
         </div>
       </div>
     </div>
@@ -229,6 +230,8 @@ const startEdit = () => {
   }
 }
 .shape-text {
+  width:100%;
+  height:100%;
   position: absolute;
   top: 0;
   bottom: 0;
@@ -236,8 +239,7 @@ const startEdit = () => {
   right: 0;
   display: flex;
   flex-direction: column;
-  padding: 10px;
-  line-height: 1.2;
+  padding: 5px;
   word-break: break-word;
   pointer-events: none;
 
@@ -250,6 +252,10 @@ const startEdit = () => {
   }
   &.middle {
     justify-content: center;
+    left: 50%;
+    top: 50%;
+    -webkit-transform: translate(-50%,-50%);
+    transform: translate(-50%,-50%);
   }
   &.bottom {
     justify-content: flex-end;

+ 3 - 0
src/views/components/element/TextElement/BaseTextElement.vue

@@ -25,6 +25,9 @@
           color: elementInfo.defaultColor,
           fontFamily: elementInfo.defaultFontName,
           writingMode: elementInfo.vertical ? 'vertical-rl' : 'horizontal-tb',
+          display: 'flex',
+          alignItems: 'center',
+          overflow: hidden,
         }"
       >
         <ElementOutline

+ 49 - 14
src/views/components/element/TextElement/index.vue

@@ -18,7 +18,7 @@
         ref="elementRef"
         :style="{
           width: elementInfo.vertical ? 'auto' : elementInfo.width + 'px',
-          height: elementInfo.vertical ? elementInfo.height + 'px' : 'auto',
+          height: elementInfo.vertical ? elementInfo.height + 'px' :  elementInfo.height + 'px',
           backgroundColor: elementInfo.fill,
           opacity: elementInfo.opacity,
           textShadow: shadowStyle,
@@ -27,6 +27,7 @@
           color: elementInfo.defaultColor,
           fontFamily: elementInfo.defaultFontName,
           writingMode: elementInfo.vertical ? 'vertical-rl' : 'horizontal-tb',
+
         }"
         v-contextmenu="contextmenus"
         @mousedown="$event => handleSelectElement($event)"
@@ -37,19 +38,21 @@
           :height="elementInfo.height"
           :outline="elementInfo.outline"
         />
-        <ProsemirrorEditor
-          class="text"
-          :elementId="elementInfo.id"
-          :defaultColor="elementInfo.defaultColor"
-          :defaultFontName="elementInfo.defaultFontName"
-          :editable="!elementInfo.lock"
-          :value="elementInfo.content"
-          :style="{
-            '--paragraphSpace': `${elementInfo.paragraphSpace === undefined ? 5 : elementInfo.paragraphSpace}px`,
-          }"
-          @update="({ value, ignore }) => updateContent(value, ignore)"
-          @mousedown="$event => handleSelectElement($event, false)"
-        />
+        <div class="shape-text" :style="elementInfo.style" :class="[elementInfo.align, { 'editable': editable || elementInfo.content }]">
+          <ProsemirrorEditor
+            class="text"
+            :elementId="elementInfo.id"
+            :defaultColor="elementInfo.defaultColor"
+            :defaultFontName="elementInfo.defaultFontName"
+            :editable="!elementInfo.lock"
+            :value="elementInfo.content"
+            :style="{
+              '--paragraphSpace': `${elementInfo.paragraphSpace === undefined ? 5 : elementInfo.paragraphSpace}px`,
+            }"
+            @update="({ value, ignore }) => updateContent(value, ignore)"
+            @mousedown="$event => handleSelectElement($event, false)"
+          />
+        </div>
 
         <!-- 当字号过大且行高较小时,会出现文字高度溢出的情况,导致拖拽区域无法被选中,因此添加了以下节点避免该情况 -->
         <div class="drag-handler top"></div>
@@ -217,4 +220,36 @@ watch(isHandleElement, () => {
     bottom: 0;
   }
 }
+
+.shape-text {
+  width:100%;
+  height:100%;
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  display: flex;
+  flex-direction: column;
+  word-break: break-word;
+  pointer-events: none;
+
+  &.editable {
+    pointer-events: all;
+  }
+
+  &.top {
+    justify-content: flex-start;
+  }
+  &.middle {
+    justify-content: center;
+    left: 50%;
+    top: 50%;
+    -webkit-transform: translate(-50%,-50%);
+    transform: translate(-50%,-50%);
+  }
+  &.bottom {
+    justify-content: flex-end;
+  }
+}
 </style>

+ 3 - 0
src/views/lang/cn.json

@@ -0,0 +1,3 @@
+ {
+  "lang": "cn"
+ }

+ 3 - 0
src/views/lang/en.json

@@ -0,0 +1,3 @@
+ {
+  "lang": "en"
+ }

+ 3 - 0
src/views/lang/hk.json

@@ -0,0 +1,3 @@
+ {
+  "lang": "hk"
+ }