Просмотр исходного кода

Merge branch 'master' of https://git.cocorobo.cn/jack/PPT

SanHQin 11 часов назад
Родитель
Сommit
f7d53e7ada
100 измененных файлов с 9035 добавлено и 645 удалено
  1. 1 0
      .gitignore
  2. 12 4
      doc/CustomElement.md
  3. 8 1
      index.html
  4. 462 50
      package-lock.json
  5. 5 2
      package.json
  6. 1160 0
      public/UTIF.js
  7. 91 22
      src/App.vue
  8. 3 0
      src/assets/img/Subtract.svg
  9. BIN
      src/assets/img/ai_agent_header2.png
  10. 4 0
      src/assets/img/arrow.svg
  11. BIN
      src/assets/img/arrow_left.png
  12. BIN
      src/assets/img/arrow_up.png
  13. BIN
      src/assets/img/close.png
  14. 5 0
      src/assets/img/left-a.svg
  15. BIN
      src/assets/img/loading.gif
  16. 5 0
      src/assets/img/right-a.svg
  17. BIN
      src/assets/img/tool_answer.png
  18. BIN
      src/assets/img/tool_choice.jpeg
  19. 1 2
      src/assets/styles/prosemirror.scss
  20. 864 0
      src/components/CollapsibleToolbar/componets/aiChat.vue
  21. 640 0
      src/components/CollapsibleToolbar/index.vue
  22. 1578 0
      src/components/CollapsibleToolbar/index2.vue
  23. 48 0
      src/components/CollapsibleToolbar/page/ContentPage.json
  24. 38 0
      src/components/CollapsibleToolbar/page/ImagePage.json
  25. 64 0
      src/components/CollapsibleToolbar/page/ImageTextPage.json
  26. 36 0
      src/components/CollapsibleToolbar/page/TitlePage.json
  27. 91 0
      src/components/ConfirmDialog.vue
  28. 4 2
      src/components/Contextmenu/MenuContent.vue
  29. 426 0
      src/components/CreateCourseDialog.vue
  30. 47 0
      src/components/FileInput2.vue
  31. 9 8
      src/components/LaTeXEditor/index.vue
  32. 2 1
      src/components/Message.vue
  33. 6 2
      src/components/Modal.vue
  34. 220 0
      src/components/MoveablePanel2.vue
  35. 3 2
      src/components/Popover.vue
  36. 6 4
      src/components/PopoverMenuItem.vue
  37. 40 11
      src/components/WritingBoard.vue
  38. 61 51
      src/configs/chart.ts
  39. 14 12
      src/configs/element.ts
  40. 24 22
      src/configs/latex.ts
  41. 3 3
      src/configs/lines.ts
  42. 6 5
      src/configs/shapes.ts
  43. 49 0
      src/global.d.ts
  44. 4 2
      src/hooks/useCreateElement.ts
  45. 954 50
      src/hooks/useImport.ts
  46. 3 2
      src/hooks/useLink.ts
  47. 4 3
      src/hooks/useSearch.ts
  48. 27 1
      src/hooks/useSlideHandler.ts
  49. 9 0
      src/i18n/lang.ts
  50. 27 0
      src/main.ts
  51. 1 1
      src/plugins/directive/tooltip.scss
  52. 6 0
      src/plugins/icon.ts
  53. 52 5
      src/services/config.ts
  54. 31 2
      src/services/course.ts
  55. 1 1
      src/store/main.ts
  56. 3 1
      src/store/slides.ts
  57. 244 0
      src/tools/aiChat.ts
  58. 5 1
      src/types/slides.ts
  59. 1 1
      src/utils/prosemirror/schema/marks.ts
  60. 1 1
      src/utils/prosemirror/schema/nodes.ts
  61. 41 40
      src/views/Editor/AIPPTDialog.vue
  62. 29 37
      src/views/Editor/Canvas/EditableElement.vue
  63. 8 7
      src/views/Editor/Canvas/LinkDialog.vue
  64. 4 3
      src/views/Editor/Canvas/Operate/LinkHandler.vue
  65. 2 1
      src/views/Editor/Canvas/ShapeCreateCanvas.vue
  66. 7 6
      src/views/Editor/Canvas/WebpageLinkEditDialog.vue
  67. 1 1
      src/views/Editor/Canvas/hooks/useScaleElement.ts
  68. 20 13
      src/views/Editor/Canvas/index.vue
  69. 4 2
      src/views/Editor/CanvasTool/ChartPool.vue
  70. 11 10
      src/views/Editor/CanvasTool/MediaInput.vue
  71. 1 1
      src/views/Editor/CanvasTool/ShapePool.vue
  72. 9 8
      src/views/Editor/CanvasTool/TableGenerator.vue
  73. 32 13
      src/views/Editor/CanvasTool/WebpageInput.vue
  74. 70 27
      src/views/Editor/CanvasTool/index.vue
  75. 578 0
      src/views/Editor/CanvasTool/index2.vue
  76. 39 22
      src/views/Editor/EditorHeader/index.vue
  77. 14 13
      src/views/Editor/ExportDialog/ExportImage.vue
  78. 3 2
      src/views/Editor/ExportDialog/ExportJSON.vue
  79. 10 9
      src/views/Editor/ExportDialog/ExportPDF.vue
  80. 15 14
      src/views/Editor/ExportDialog/ExportPPTX.vue
  81. 10 9
      src/views/Editor/ExportDialog/ExportSpecificFile.vue
  82. 6 5
      src/views/Editor/ExportDialog/index.vue
  83. 27 26
      src/views/Editor/MarkupPanel.vue
  84. 19 13
      src/views/Editor/NotesPanel.vue
  85. 3 2
      src/views/Editor/Remark/Editor.vue
  86. 10 9
      src/views/Editor/SearchPanel.vue
  87. 16 9
      src/views/Editor/SelectPanel.vue
  88. 555 0
      src/views/Editor/Thumbnails/index2.vue
  89. 2 2
      src/views/Editor/Toolbar/ElementAnimationPanel.vue
  90. 4 3
      src/views/Editor/Toolbar/ElementStylePanel/AudioStylePanel.vue
  91. 10 8
      src/views/Editor/Toolbar/ElementStylePanel/ChartStylePanel/ChartDataEditor.vue
  92. 6 5
      src/views/Editor/Toolbar/ElementStylePanel/ChartStylePanel/ThemeColorsSetting.vue
  93. 11 10
      src/views/Editor/Toolbar/ElementStylePanel/ChartStylePanel/index.vue
  94. 4 3
      src/views/Editor/Toolbar/ElementStylePanel/FrameStylePanel.vue
  95. 11 10
      src/views/Editor/Toolbar/ElementStylePanel/ImageStylePanel.vue
  96. 4 3
      src/views/Editor/Toolbar/ElementStylePanel/LatexStylePanel.vue
  97. 7 6
      src/views/Editor/Toolbar/ElementStylePanel/LineStylePanel.vue
  98. 14 13
      src/views/Editor/Toolbar/ElementStylePanel/ShapeStylePanel.vue
  99. 4 3
      src/views/Editor/Toolbar/ElementStylePanel/VideoStylePanel.vue
  100. 5 2
      src/views/Editor/index.vue

+ 1 - 0
.gitignore

@@ -10,6 +10,7 @@ lerna-debug.log*
 node_modules
 .DS_Store
 dist
+package-lock.json
 dist-ssr
 coverage
 *.local

+ 12 - 4
doc/CustomElement.md

@@ -192,14 +192,20 @@ const handleSelectElement = (e: MouseEvent | TouchEvent, canMove = true) => {
 此外我们需要另一个不带编辑功能的基础版组件,用于缩略图/放映模式下显示:
 ```html
 <!-- views/components/element/FrameElement/BaseFrameElement.vue -->
+      <!-- 
+      width: elementInfo.width + 'px',
+      height: elementInfo.height + 'px',
 
+                :width="elementInfo.width"
+          :height="elementInfo.height"
+      -->
 <template>
   <div class="base-element-frame"
     :style="{
       top: elementInfo.top + 'px',
       left: elementInfo.left + 'px',
-      width: elementInfo.width + 'px',
-      height: elementInfo.height + 'px',
+      width: 100%,
+      height: 100%,
     }"
   >
     <div
@@ -208,9 +214,11 @@ const handleSelectElement = (e: MouseEvent | TouchEvent, canMove = true) => {
     >
       <div class="element-content">
         <iframe 
+          :style="{
+            width: 100%,
+            height: 100%,
+          }"
           :src="elementInfo.url"
-          :width="elementInfo.width"
-          :height="elementInfo.height"
           :frameborder="0" 
           :allowfullscreen="true"
         ></iframe>

+ 8 - 1
index.html

@@ -8,6 +8,13 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <meta name="description" content="PPTist - 在线演示文稿(幻灯片)应用,还原了大部分 Office PowerPoint 常用功能,实现在线PPT的编辑、演示,支持导出PPT文件,支持AI生成PPT。" />
     <meta name="keywords" content="pptist,ppt,powerpoint,office powerpoint,在线ppt,幻灯片,演示文稿,ppt在线制作,aippt" />
+    <meta http-equiv="Content-Security-Policy" 
+      content="default-src * data: blob: 'unsafe-inline' 'unsafe-eval';
+               media-src * data: blob:;
+               script-src * 'unsafe-inline' 'unsafe-eval';
+               style-src * 'unsafe-inline' 'unsafe-hashes';
+               img-src * data: blob:;
+               font-src * data:;">
     <title>PPTist - 在线演示文稿</title>
 
     <style>
@@ -51,7 +58,7 @@
     <div id="app">
       <div class="first-screen-loading">
         <div class="first-screen-loading-spinner"></div>
-        <div class="first-screen-loading-text">正在加载中,请稍等 ...</div>
+        <!-- <div class="first-screen-loading-text">正在加载中,请稍等 ...</div> -->
       </div>
     </div>
     <script type="module" src="/src/main.ts"></script>

Разница между файлами не показана из-за своего большого размера
+ 462 - 50
package-lock.json


+ 5 - 2
package.json

@@ -29,13 +29,13 @@
     "html2canvas": "^1.4.1",
     "katex": "^0.16.22",
     "lodash": "^4.17.21",
-    "markdown-it": "^14.1.0",
+    "markdown-it": "^14.1.1",
     "mitt": "^3.0.1",
     "nanoid": "^5.0.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",
@@ -52,8 +52,11 @@
     "svg-pathdata": "^7.1.0",
     "tinycolor2": "^1.6.0",
     "tippy.js": "^6.3.7",
+    "uuid": "^13.0.0",
     "vue": "^3.5.17",
     "vuedraggable": "^4.1.0",
+    "wangeditor": "^4.7.15",
+    "webcodecs-encoder": "^0.3.2",
     "y-websocket": "^3.0.0",
     "yjs": "^13.6.27"
   },

+ 1160 - 0
public/UTIF.js

@@ -0,0 +1,1160 @@
+
+
+
+
+;(function(){
+var UTIF = {};
+
+// Make available for import by `require()`
+if (typeof module == "object") {module.exports = UTIF;}
+else {self.UTIF = UTIF;}
+
+var pako;
+if (typeof require == "function") {pako = require("pako");}
+else {pako = self.pako;}
+
+function log() { if (typeof process=="undefined" || process.env.NODE_ENV=="development") console.log.apply(console, arguments);  }
+
+(function(UTIF, pako){
+	
+// Following lines add a JPEG decoder  to UTIF.JpegDecoder
+(function(){var V="function"===typeof Symbol&&"symbol"===typeof Symbol.iterator?function(g){return typeof g}:function(g){return g&&"function"===typeof Symbol&&g.constructor===Symbol&&g!==Symbol.prototype?"symbol":typeof g},D=function(){function g(g){this.message="JPEG error: "+g}g.prototype=Error();g.prototype.name="JpegError";return g.constructor=g}(),P=function(){function g(g,D){this.message=g;this.g=D}g.prototype=Error();g.prototype.name="DNLMarkerError";return g.constructor=g}();(function(){function g(){this.M=
+null;this.B=-1}function W(a,d){for(var f=0,e=[],b,B,k=16;0<k&&!a[k-1];)k--;e.push({children:[],index:0});var l=e[0],r;for(b=0;b<k;b++){for(B=0;B<a[b];B++){l=e.pop();for(l.children[l.index]=d[f];0<l.index;)l=e.pop();l.index++;for(e.push(l);e.length<=b;)e.push(r={children:[],index:0}),l.children[l.index]=r.children,l=r;f++}b+1<k&&(e.push(r={children:[],index:0}),l.children[l.index]=r.children,l=r)}return e[0].children}function X(a,d,f,e,b,B,k,l,r){function n(){if(0<x)return x--,z>>x&1;z=a[d++];if(255===
+z){var c=a[d++];if(c){if(220===c&&g){d+=2;var b=a[d++]<<8|a[d++];if(0<b&&b!==f.g)throw new P("Found DNL marker (0xFFDC) while parsing scan data",b);}throw new D("unexpected marker "+(z<<8|c).toString(16));}}x=7;return z>>>7}function q(a){for(;;){a=a[n()];if("number"===typeof a)return a;if("object"!==("undefined"===typeof a?"undefined":V(a)))throw new D("invalid huffman sequence");}}function h(a){for(var c=0;0<a;)c=c<<1|n(),a--;return c}function c(a){if(1===a)return 1===n()?1:-1;var c=h(a);return c>=
+1<<a-1?c:c+(-1<<a)+1}function C(a,b){var d=q(a.D);d=0===d?0:c(d);a.a[b]=a.m+=d;for(d=1;64>d;){var h=q(a.o),k=h&15;h>>=4;if(0===k){if(15>h)break;d+=16}else d+=h,a.a[b+J[d]]=c(k),d++}}function w(a,d){var b=q(a.D);b=0===b?0:c(b)<<r;a.a[d]=a.m+=b}function p(a,c){a.a[c]|=n()<<r}function m(a,b){if(0<A)A--;else for(var d=B;d<=k;){var e=q(a.o),f=e&15;e>>=4;if(0===f){if(15>e){A=h(e)+(1<<e)-1;break}d+=16}else d+=e,a.a[b+J[d]]=c(f)*(1<<r),d++}}function t(a,d){for(var b=B,e=0,f;b<=k;){f=d+J[b];var l=0>a.a[f]?
+-1:1;switch(E){case 0:e=q(a.o);f=e&15;e>>=4;if(0===f)15>e?(A=h(e)+(1<<e),E=4):(e=16,E=1);else{if(1!==f)throw new D("invalid ACn encoding");Q=c(f);E=e?2:3}continue;case 1:case 2:a.a[f]?a.a[f]+=l*(n()<<r):(e--,0===e&&(E=2===E?3:0));break;case 3:a.a[f]?a.a[f]+=l*(n()<<r):(a.a[f]=Q<<r,E=0);break;case 4:a.a[f]&&(a.a[f]+=l*(n()<<r))}b++}4===E&&(A--,0===A&&(E=0))}var g=9<arguments.length&&void 0!==arguments[9]?arguments[9]:!1,u=f.P,v=d,z=0,x=0,A=0,E=0,Q,K=e.length,F,L,M,I;var R=f.S?0===B?0===l?w:p:0===l?
+m:t:C;var G=0;var O=1===K?e[0].c*e[0].l:u*f.O;for(var S,T;G<O;){var U=b?Math.min(O-G,b):O;for(F=0;F<K;F++)e[F].m=0;A=0;if(1===K){var y=e[0];for(I=0;I<U;I++)R(y,64*((y.c+1)*(G/y.c|0)+G%y.c)),G++}else for(I=0;I<U;I++){for(F=0;F<K;F++)for(y=e[F],S=y.h,T=y.j,L=0;L<T;L++)for(M=0;M<S;M++)R(y,64*((y.c+1)*((G/u|0)*y.j+L)+(G%u*y.h+M)));G++}x=0;(y=N(a,d))&&y.f&&((0,_util.warn)("decodeScan - unexpected MCU data, current marker is: "+y.f),d=y.offset);y=y&&y.F;if(!y||65280>=y)throw new D("marker was not found");
+if(65488<=y&&65495>=y)d+=2;else break}(y=N(a,d))&&y.f&&((0,_util.warn)("decodeScan - unexpected Scan data, current marker is: "+y.f),d=y.offset);return d-v}function Y(a,d){for(var f=d.c,e=d.l,b=new Int16Array(64),B=0;B<e;B++)for(var k=0;k<f;k++){var l=64*((d.c+1)*B+k),r=b,n=d.G,q=d.a;if(!n)throw new D("missing required Quantization Table.");for(var h=0;64>h;h+=8){var c=q[l+h];var C=q[l+h+1];var w=q[l+h+2];var p=q[l+h+3];var m=q[l+h+4];var t=q[l+h+5];var g=q[l+h+6];var u=q[l+h+7];c*=n[h];if(0===(C|
+w|p|m|t|g|u))c=5793*c+512>>10,r[h]=c,r[h+1]=c,r[h+2]=c,r[h+3]=c,r[h+4]=c,r[h+5]=c,r[h+6]=c,r[h+7]=c;else{C*=n[h+1];w*=n[h+2];p*=n[h+3];m*=n[h+4];t*=n[h+5];g*=n[h+6];u*=n[h+7];var v=5793*c+128>>8;var z=5793*m+128>>8;var x=w;var A=g;m=2896*(C-u)+128>>8;u=2896*(C+u)+128>>8;p<<=4;t<<=4;v=v+z+1>>1;z=v-z;c=3784*x+1567*A+128>>8;x=1567*x-3784*A+128>>8;A=c;m=m+t+1>>1;t=m-t;u=u+p+1>>1;p=u-p;v=v+A+1>>1;A=v-A;z=z+x+1>>1;x=z-x;c=2276*m+3406*u+2048>>12;m=3406*m-2276*u+2048>>12;u=c;c=799*p+4017*t+2048>>12;p=4017*
+p-799*t+2048>>12;t=c;r[h]=v+u;r[h+7]=v-u;r[h+1]=z+t;r[h+6]=z-t;r[h+2]=x+p;r[h+5]=x-p;r[h+3]=A+m;r[h+4]=A-m}}for(n=0;8>n;++n)c=r[n],C=r[n+8],w=r[n+16],p=r[n+24],m=r[n+32],t=r[n+40],g=r[n+48],u=r[n+56],0===(C|w|p|m|t|g|u)?(c=5793*c+8192>>14,c=-2040>c?0:2024<=c?255:c+2056>>4,q[l+n]=c,q[l+n+8]=c,q[l+n+16]=c,q[l+n+24]=c,q[l+n+32]=c,q[l+n+40]=c,q[l+n+48]=c,q[l+n+56]=c):(v=5793*c+2048>>12,z=5793*m+2048>>12,x=w,A=g,m=2896*(C-u)+2048>>12,u=2896*(C+u)+2048>>12,v=(v+z+1>>1)+4112,z=v-z,c=3784*x+1567*A+2048>>
+12,x=1567*x-3784*A+2048>>12,A=c,m=m+t+1>>1,t=m-t,u=u+p+1>>1,p=u-p,v=v+A+1>>1,A=v-A,z=z+x+1>>1,x=z-x,c=2276*m+3406*u+2048>>12,m=3406*m-2276*u+2048>>12,u=c,c=799*p+4017*t+2048>>12,p=4017*p-799*t+2048>>12,t=c,c=v+u,u=v-u,C=z+t,g=z-t,w=x+p,t=x-p,p=A+m,m=A-m,c=16>c?0:4080<=c?255:c>>4,C=16>C?0:4080<=C?255:C>>4,w=16>w?0:4080<=w?255:w>>4,p=16>p?0:4080<=p?255:p>>4,m=16>m?0:4080<=m?255:m>>4,t=16>t?0:4080<=t?255:t>>4,g=16>g?0:4080<=g?255:g>>4,u=16>u?0:4080<=u?255:u>>4,q[l+n]=c,q[l+n+8]=C,q[l+n+16]=w,q[l+n+24]=
+p,q[l+n+32]=m,q[l+n+40]=t,q[l+n+48]=g,q[l+n+56]=u)}return d.a}function N(a,d){var f=2<arguments.length&&void 0!==arguments[2]?arguments[2]:d,e=a.length-1;f=f<d?f:d;if(d>=e)return null;var b=a[d]<<8|a[d+1];if(65472<=b&&65534>=b)return{f:null,F:b,offset:d};for(var B=a[f]<<8|a[f+1];!(65472<=B&&65534>=B);){if(++f>=e)return null;B=a[f]<<8|a[f+1]}return{f:b.toString(16),F:B,offset:f}}var J=new Uint8Array([0,1,8,16,9,2,3,10,17,24,32,25,18,11,4,5,12,19,26,33,40,48,41,34,27,20,13,6,7,14,21,28,35,42,49,56,
+57,50,43,36,29,22,15,23,30,37,44,51,58,59,52,45,38,31,39,46,53,60,61,54,47,55,62,63]);g.prototype={parse:function(a){function d(){var d=a[k]<<8|a[k+1];k+=2;return d}function f(){var b=d();b=k+b-2;var c=N(a,b,k);c&&c.f&&((0,_util.warn)("readDataBlock - incorrect length, current marker is: "+c.f),b=c.offset);b=a.subarray(k,b);k+=b.length;return b}function e(a){for(var b=Math.ceil(a.v/8/a.s),c=Math.ceil(a.g/8/a.u),d=0;d<a.b.length;d++){v=a.b[d];var e=Math.ceil(Math.ceil(a.v/8)*v.h/a.s),f=Math.ceil(Math.ceil(a.g/
+8)*v.j/a.u);v.a=new Int16Array(64*c*v.j*(b*v.h+1));v.c=e;v.l=f}a.P=b;a.O=c}var b=(1<arguments.length&&void 0!==arguments[1]?arguments[1]:{}).N,B=void 0===b?null:b,k=0,l=null,r=0;b=[];var n=[],q=[],h=d();if(65496!==h)throw new D("SOI not found");for(h=d();65497!==h;){switch(h){case 65504:case 65505:case 65506:case 65507:case 65508:case 65509:case 65510:case 65511:case 65512:case 65513:case 65514:case 65515:case 65516:case 65517:case 65518:case 65519:case 65534:var c=f();65518===h&&65===c[0]&&100===
+c[1]&&111===c[2]&&98===c[3]&&101===c[4]&&(l={version:c[5]<<8|c[6],Y:c[7]<<8|c[8],Z:c[9]<<8|c[10],W:c[11]});break;case 65499:h=d()+k-2;for(var g;k<h;){var w=a[k++],p=new Uint16Array(64);if(0===w>>4)for(c=0;64>c;c++)g=J[c],p[g]=a[k++];else if(1===w>>4)for(c=0;64>c;c++)g=J[c],p[g]=d();else throw new D("DQT - invalid table spec");b[w&15]=p}break;case 65472:case 65473:case 65474:if(m)throw new D("Only single frame JPEGs supported");d();var m={};m.X=65473===h;m.S=65474===h;m.precision=a[k++];h=d();m.g=
+B||h;m.v=d();m.b=[];m.C={};c=a[k++];for(h=p=w=0;h<c;h++){g=a[k];var t=a[k+1]>>4;var H=a[k+1]&15;w<t&&(w=t);p<H&&(p=H);t=m.b.push({h:t,j:H,T:a[k+2],G:null});m.C[g]=t-1;k+=3}m.s=w;m.u=p;e(m);break;case 65476:g=d();for(h=2;h<g;){w=a[k++];p=new Uint8Array(16);for(c=t=0;16>c;c++,k++)t+=p[c]=a[k];H=new Uint8Array(t);for(c=0;c<t;c++,k++)H[c]=a[k];h+=17+t;(0===w>>4?q:n)[w&15]=W(p,H)}break;case 65501:d();var u=d();break;case 65498:c=1===++r&&!B;d();w=a[k++];g=[];for(h=0;h<w;h++){p=m.C[a[k++]];var v=m.b[p];
+p=a[k++];v.D=q[p>>4];v.o=n[p&15];g.push(v)}h=a[k++];w=a[k++];p=a[k++];try{var z=X(a,k,m,g,u,h,w,p>>4,p&15,c);k+=z}catch(x){if(x instanceof P)return(0,_util.warn)('Attempting to re-parse JPEG image using "scanLines" parameter found in DNL marker (0xFFDC) segment.'),this.parse(a,{N:x.g});throw x;}break;case 65500:k+=4;break;case 65535:255!==a[k]&&k--;break;default:if(255===a[k-3]&&192<=a[k-2]&&254>=a[k-2])k-=3;else if((c=N(a,k-2))&&c.f)(0,_util.warn)("JpegImage.parse - unexpected data, current marker is: "+
+c.f),k=c.offset;else throw new D("unknown marker "+h.toString(16));}h=d()}this.width=m.v;this.height=m.g;this.A=l;this.b=[];for(h=0;h<m.b.length;h++){v=m.b[h];if(u=b[v.T])v.G=u;this.b.push({R:Y(m,v),U:v.h/m.s,V:v.j/m.u,c:v.c,l:v.l})}this.i=this.b.length},L:function(a,d){var f=this.width/a,e=this.height/d,b,g,k=this.b.length,l=a*d*k,r=new Uint8ClampedArray(l),n=new Uint32Array(a);for(g=0;g<k;g++){var q=this.b[g];var h=q.U*f;var c=q.V*e;var C=g;var w=q.R;var p=q.c+1<<3;for(b=0;b<a;b++)q=0|b*h,n[b]=
+(q&4294967288)<<3|q&7;for(h=0;h<d;h++)for(q=0|h*c,q=p*(q&4294967288)|(q&7)<<3,b=0;b<a;b++)r[C]=w[q+n[b]],C+=k}if(e=this.M)for(g=0;g<l;)for(f=q=0;q<k;q++,g++,f+=2)r[g]=(r[g]*e[f]>>8)+e[f+1];return r},w:function(){return this.A?!!this.A.W:3===this.i?0===this.B?!1:!0:1===this.B?!0:!1},I:function(a){for(var d,f,e,b=0,g=a.length;b<g;b+=3)d=a[b],f=a[b+1],e=a[b+2],a[b]=d-179.456+1.402*e,a[b+1]=d+135.459-.344*f-.714*e,a[b+2]=d-226.816+1.772*f;return a},K:function(a){for(var d,f,e,b,g=0,k=0,l=a.length;k<l;k+=
+4)d=a[k],f=a[k+1],e=a[k+2],b=a[k+3],a[g++]=-122.67195406894+f*(-6.60635669420364E-5*f+4.37130475926232E-4*e-5.4080610064599E-5*d+4.8449797120281E-4*b-.154362151871126)+e*(-9.57964378445773E-4*e+8.17076911346625E-4*d-.00477271405408747*b+1.53380253221734)+d*(9.61250184130688E-4*d-.00266257332283933*b+.48357088451265)+b*(-3.36197177618394E-4*b+.484791561490776),a[g++]=107.268039397724+f*(2.19927104525741E-5*f-6.40992018297945E-4*e+6.59397001245577E-4*d+4.26105652938837E-4*b-.176491792462875)+e*(-7.78269941513683E-4*
+e+.00130872261408275*d+7.70482631801132E-4*b-.151051492775562)+d*(.00126935368114843*d-.00265090189010898*b+.25802910206845)+b*(-3.18913117588328E-4*b-.213742400323665),a[g++]=-20.810012546947+f*(-5.70115196973677E-4*f-2.63409051004589E-5*e+.0020741088115012*d-.00288260236853442*b+.814272968359295)+e*(-1.53496057440975E-5*e-1.32689043961446E-4*d+5.60833691242812E-4*b-.195152027534049)+d*(.00174418132927582*d-.00255243321439347*b+.116935020465145)+b*(-3.43531996510555E-4*b+.24165260232407);return a.subarray(0,
+g)},J:function(a){for(var d,f,e,b=0,g=a.length;b<g;b+=4)d=a[b],f=a[b+1],e=a[b+2],a[b]=434.456-d-1.402*e,a[b+1]=119.541-d+.344*f+.714*e,a[b+2]=481.816-d-1.772*f;return a},H:function(a){for(var d,f,e,b,g=0,k=1/255,l=0,r=a.length;l<r;l+=4)d=a[l]*k,f=a[l+1]*k,e=a[l+2]*k,b=a[l+3]*k,a[g++]=255+d*(-4.387332384609988*d+54.48615194189176*f+18.82290502165302*e+212.25662451639585*b-285.2331026137004)+f*(1.7149763477362134*f-5.6096736904047315*e-17.873870861415444*b-5.497006427196366)+e*(-2.5217340131683033*
+e-21.248923337353073*b+17.5119270841813)-b*(21.86122147463605*b+189.48180835922747),a[g++]=255+d*(8.841041422036149*d+60.118027045597366*f+6.871425592049007*e+31.159100130055922*b-79.2970844816548)+f*(-15.310361306967817*f+17.575251261109482*e+131.35250912493976*b-190.9453302588951)+e*(4.444339102852739*e+9.8632861493405*b-24.86741582555878)-b*(20.737325471181034*b+187.80453709719578),a[g++]=255+d*(.8842522430003296*d+8.078677503112928*f+30.89978309703729*e-.23883238689178934*b-14.183576799673286)+
+f*(10.49593273432072*f+63.02378494754052*e+50.606957656360734*b-112.23884253719248)+e*(.03296041114873217*e+115.60384449646641*b-193.58209356861505)-b*(22.33816807309886*b+180.12613974708367);return a.subarray(0,g)},getData:function(a,d,f){if(4<this.i)throw new D("Unsupported color mode");a=this.L(a,d);if(1===this.i&&f){f=a.length;d=new Uint8ClampedArray(3*f);for(var e=0,b=0;b<f;b++){var g=a[b];d[e++]=g;d[e++]=g;d[e++]=g}return d}if(3===this.i&&this.w())return this.I(a);if(4===this.i){if(this.w())return f?
+this.K(a):this.J(a);if(f)return this.H(a)}return a}}; UTIF.JpegDecoder=g})()})();
+
+//UTIF.JpegDecoder = PDFJS.JpegImage;
+
+
+UTIF.encodeImage = function(rgba, w, h, metadata)
+{
+	var idf = { "t256":[w], "t257":[h], "t258":[8,8,8,8], "t259":[1], "t262":[2], "t273":[1000], // strips offset
+				"t277":[4], "t278":[h], /* rows per strip */          "t279":[w*h*4], // strip byte counts
+				"t282":[1], "t283":[1], "t284":[1], "t286":[0], "t287":[0], "t296":[1], "t305": ["Photopea (UTIF.js)"], "t338":[1]
+		};
+	if (metadata) for (var i in metadata) idf[i] = metadata[i];
+	
+	var prfx = new Uint8Array(UTIF.encode([idf]));
+	var img = new Uint8Array(rgba);
+	var data = new Uint8Array(1000+w*h*4);
+	for(var i=0; i<prfx.length; i++) data[i] = prfx[i];
+	for(var i=0; i<img .length; i++) data[1000+i] = img[i];
+	return data.buffer;
+}
+
+UTIF.encode = function(ifds)
+{
+	var data = new Uint8Array(20000), offset = 4, bin = UTIF._binBE;
+	data[0]=77;  data[1]=77;  data[3]=42;
+
+	var ifdo = 8;
+	bin.writeUint(data, offset, ifdo);  offset+=4;
+	for(var i=0; i<ifds.length; i++)
+	{
+		var noffs = UTIF._writeIFD(bin, data, ifdo, ifds[i]);
+		ifdo = noffs[1];
+		if(i<ifds.length-1) bin.writeUint(data, noffs[0], ifdo);
+	}
+	return data.slice(0, ifdo).buffer;
+}
+//UTIF.encode._writeIFD
+
+UTIF.decode = function(buff)
+{
+	UTIF.decode._decodeG3.allow2D = null;
+	var data = new Uint8Array(buff), offset = 0;
+
+	var id = UTIF._binBE.readASCII(data, offset, 2);  offset+=2;
+	var bin = id=="II" ? UTIF._binLE : UTIF._binBE;
+	var num = bin.readUshort(data, offset);  offset+=2;
+
+	var ifdo = bin.readUint(data, offset);  offset+=4;
+	var ifds = [];
+	while(true) {
+		var noff = UTIF._readIFD(bin, data, ifdo, ifds, 0, false);
+		ifdo = bin.readUint(data, noff);
+		if(ifdo==0) break;
+	}
+	return ifds;
+}
+
+UTIF.decodeImage = function(buff, img, ifds)
+{
+	var data = new Uint8Array(buff);
+	var id = UTIF._binBE.readASCII(data, 0, 2);
+
+	if(img["t256"]==null) return;	// No width => probably not an image
+	img.isLE = id=="II";
+	img.width  = img["t256"][0];  //delete img["t256"];
+	img.height = img["t257"][0];  //delete img["t257"];
+
+	var cmpr   = img["t259"] ? img["t259"][0] : 1;  //delete img["t259"];
+	var fo = img["t266"] ? img["t266"][0] : 1;  //delete img["t266"];
+	if(img["t284"] && img["t284"][0]==2) log("PlanarConfiguration 2 should not be used!");
+
+	var bipp;  // bits per pixel
+	if(img["t258"]) bipp = Math.min(32,img["t258"][0])*img["t258"].length;
+	else            bipp = (img["t277"]?img["t277"][0]:1);  
+	// Some .NEF files have t258==14, even though they use 16 bits per pixel
+	if(cmpr==1 && img["t279"]!=null && img["t278"] && img["t262"][0]==32803)  {
+		bipp = Math.round((img["t279"][0]*8)/(img.width*img["t278"][0]));
+	}
+	var bipl = Math.ceil(img.width*bipp/8)*8;
+	var soff = img["t273"];  if(soff==null) soff = img["t324"];
+	var bcnt = img["t279"];  if(cmpr==1 && soff.length==1) bcnt = [img.height*(bipl>>>3)];  if(bcnt==null) bcnt = img["t325"];
+	var bytes = new Uint8Array(img.height*(bipl>>>3)), bilen = 0;
+
+	if(img["t322"]!=null) // tiled
+	{
+		var tw = img["t322"][0], th = img["t323"][0];
+		var tx = Math.floor((img.width  + tw - 1) / tw);
+		var ty = Math.floor((img.height + th - 1) / th);
+		var tbuff = new Uint8Array(Math.ceil(tw*th*bipp/8)|0);
+		for(var y=0; y<ty; y++)
+			for(var x=0; x<tx; x++)
+			{
+				var i = y*tx+x;  for(var j=0; j<tbuff.length; j++) tbuff[j]=0;
+				UTIF.decode._decompress(img,ifds, data, soff[i], bcnt[i], cmpr, tbuff, 0, fo);
+				// Might be required for 7 too. Need to check
+				if (cmpr==6) bytes = tbuff;
+				else UTIF._copyTile(tbuff, Math.ceil(tw*bipp/8)|0, th, bytes, Math.ceil(img.width*bipp/8)|0, img.height, Math.ceil(x*tw*bipp/8)|0, y*th);
+			}
+		bilen = bytes.length*8;
+	}
+	else	// stripped
+	{
+		var rps = img["t278"] ? img["t278"][0] : img.height;   rps = Math.min(rps, img.height);
+		for(var i=0; i<soff.length; i++)
+		{
+			UTIF.decode._decompress(img,ifds, data, soff[i], bcnt[i], cmpr, bytes, Math.ceil(bilen/8)|0, fo);
+			bilen += bipl * rps;
+		}
+		bilen = Math.min(bilen, bytes.length*8);
+	}
+	img.data = new Uint8Array(bytes.buffer, 0, Math.ceil(bilen/8)|0);
+}
+
+UTIF.decode._decompress = function(img,ifds, data, off, len, cmpr, tgt, toff, fo)  // fill order
+{
+	//console.log("compression", cmpr);
+	//var time = Date.now();
+	if(false) {}
+	else if(cmpr==1 || (len==tgt.length && cmpr!=32767)) for(var j=0; j<len; j++) tgt[toff+j] = data[off+j];
+	else if(cmpr==3) UTIF.decode._decodeG3 (data, off, len, tgt, toff, img.width, fo);
+	else if(cmpr==4) UTIF.decode._decodeG4 (data, off, len, tgt, toff, img.width, fo);
+	else if(cmpr==5) UTIF.decode._decodeLZW(data, off, tgt, toff);
+	else if(cmpr==6) UTIF.decode._decodeOldJPEG(img, data, off, len, tgt, toff);
+	else if(cmpr==7) UTIF.decode._decodeNewJPEG(img, data, off, len, tgt, toff);
+	else if(cmpr==8) {  var src = new Uint8Array(data.buffer,off,len);  var bin = pako["inflate"](src);  for(var i=0; i<bin.length; i++) tgt[toff+i]=bin[i];  }
+	else if(cmpr==32767) UTIF.decode._decodeARW(img, data, off, len, tgt, toff);
+	else if(cmpr==32773) UTIF.decode._decodePackBits(data, off, len, tgt, toff);
+	else if(cmpr==32809) UTIF.decode._decodeThunder (data, off, len, tgt, toff);
+	else if(cmpr==34713) //for(var j=0; j<len; j++) tgt[toff+j] = data[off+j];
+		UTIF.decode._decodeNikon   (img,ifds, data, off, len, tgt, toff);
+	else log("Unknown compression", cmpr);
+	
+	//console.log(Date.now()-time);
+	
+	var bps = (img["t258"]?Math.min(32,img["t258"][0]):1);
+	var noc = (img["t277"]?img["t277"][0]:1), bpp=(bps*noc)>>>3, h = (img["t278"] ? img["t278"][0] : img.height), bpl = Math.ceil(bps*noc*img.width/8);
+	
+	// convert to Little Endian  /*
+	if(bps==16 && !img.isLE && img["t33422"]==null)  // not DNG
+		for(var y=0; y<h; y++) {
+			//console.log("fixing endianity");
+			var roff = toff+y*bpl;
+			for(var x=1; x<bpl; x+=2) {  var t=tgt[roff+x];  tgt[roff+x]=tgt[roff+x-1];  tgt[roff+x-1]=t;  }
+		}  //*/
+
+	if(img["t317"] && img["t317"][0]==2)
+	{
+		for(var y=0; y<h; y++)
+		{
+			var ntoff = toff+y*bpl;
+			if(bps==16) for(var j=bpp; j<bpl; j+=2) {
+				var nv = ((tgt[ntoff+j+1]<<8)|tgt[ntoff+j])  +  ((tgt[ntoff+j-bpp+1]<<8)|tgt[ntoff+j-bpp]);
+				tgt[ntoff+j] = nv&255;  tgt[ntoff+j+1] = (nv>>>8)&255;  
+			}
+			else if(noc==3) for(var j=  3; j<bpl; j+=3)
+			{
+				tgt[ntoff+j  ] = (tgt[ntoff+j  ] + tgt[ntoff+j-3])&255;
+				tgt[ntoff+j+1] = (tgt[ntoff+j+1] + tgt[ntoff+j-2])&255;
+				tgt[ntoff+j+2] = (tgt[ntoff+j+2] + tgt[ntoff+j-1])&255;
+			}
+			else for(var j=bpp; j<bpl; j++) tgt[ntoff+j] = (tgt[ntoff+j] + tgt[ntoff+j-bpp])&255;
+		}
+	}
+}
+
+UTIF.decode._ljpeg_diff = function(data, prm, huff) {
+	var getbithuff   = UTIF.decode._getbithuff;
+	var len, diff;
+	len  = getbithuff(data, prm, huff[0], huff);
+	diff = getbithuff(data, prm, len, 0);
+	if ((diff & (1 << (len-1))) == 0)  diff -= (1 << len) - 1;
+	return diff;
+}
+UTIF.decode._decodeARW = function(img, inp, off, src_length, tgt, toff) {
+	var raw_width = img["t256"][0], height=img["t257"][0], tiff_bps=img["t258"][0];
+	var bin=(img.isLE ? UTIF._binLE : UTIF._binBE);
+	//console.log(raw_width, height, tiff_bps, raw_width*height, src_length);
+	var arw2 = (raw_width*height == src_length) || (raw_width*height*1.5 == src_length);
+	//arw2 = true;
+	//console.log("ARW2: ", arw2, raw_width*height, src_length, tgt.length);
+	if(!arw2) {  //"sony_arw_load_raw"; // not arw2
+		height+=8;
+		var prm = [off,0,0,0];
+		var huff = new Uint16Array(32770);
+		var tab = [ 0xf11,0xf10,0xe0f,0xd0e,0xc0d,0xb0c,0xa0b,0x90a,0x809,
+			0x708,0x607,0x506,0x405,0x304,0x303,0x300,0x202,0x201 ];
+		var i, c, n, col, row, sum=0;
+		var ljpeg_diff = UTIF.decode._ljpeg_diff;
+
+		huff[0] = 15;
+		for (n=i=0; i < 18; i++) {
+			var lim = 32768 >>> (tab[i] >>> 8);
+			for(var c=0; c<lim; c++) huff[++n] = tab[i];
+		}
+		for (col = raw_width; col--; )
+			for (row=0; row < height+1; row+=2) {
+				if (row == height) row = 1;
+				sum += ljpeg_diff(inp, prm, huff);
+				if (row < height) {
+					var clr =  (sum)&4095;
+					UTIF.decode._putsF(tgt, (row*raw_width+col)*tiff_bps, clr<<(16-tiff_bps));
+				}
+			}
+		return;
+	}
+	if(raw_width*height*1.5==src_length) {
+		//console.log("weird compression");
+		for(var i=0; i<src_length; i+=3) {  var b0=inp[off+i+0], b1=inp[off+i+1], b2=inp[off+i+2];  
+			tgt[toff+i]=(b1<<4)|(b0>>>4);  tgt[toff+i+1]=(b0<<4)|(b2>>>4);  tgt[toff+i+2]=(b2<<4)|(b1>>>4);  }
+		return;
+	}
+	
+	var pix = new Uint16Array(16);
+	var row, col, val, max, min, imax, imin, sh, bit, i,    dp;
+	
+	var data = new Uint8Array(raw_width+1);
+	for (row=0; row < height; row++) {
+		//fread (data, 1, raw_width, ifp);
+		for(var j=0; j<raw_width; j++) data[j]=inp[off++];
+		for (dp=0, col=0; col < raw_width-30; dp+=16) {
+			max  = 0x7ff & (val = bin.readUint(data,dp));
+			min  = 0x7ff & (val >>> 11);
+			imax = 0x0f & (val >>> 22);
+			imin = 0x0f & (val >>> 26);
+			for (sh=0; sh < 4 && 0x80 << sh <= max-min; sh++);
+			for (bit=30, i=0; i < 16; i++)
+				if      (i == imax) pix[i] = max;
+				else if (i == imin) pix[i] = min;
+				else {
+					pix[i] = ((bin.readUshort(data, dp+(bit >> 3)) >>> (bit & 7) & 0x7f) << sh) + min;
+					if (pix[i] > 0x7ff) pix[i] = 0x7ff;
+					bit += 7;
+				}
+			for (i=0; i < 16; i++, col+=2) {
+				//RAW(row,col) = curve[pix[i] << 1] >> 2;
+				var clr =  pix[i]<<1;   //clr = 0xffff;
+				UTIF.decode._putsF(tgt, (row*raw_width+col)*tiff_bps, clr<<(16-tiff_bps));
+			}
+			col -= col & 1 ? 1:31;
+		}
+	}
+}
+
+UTIF.decode._decodeNikon = function(img,imgs, data, off, src_length, tgt, toff)
+{
+	var nikon_tree = [
+    [ 0, 0,1,5,1,1,1,1,1,1,2,0,0,0,0,0,0,	/* 12-bit lossy */
+      5,4,3,6,2,7,1,0,8,9,11,10,12 ],
+    [ 0, 0,1,5,1,1,1,1,1,1,2,0,0,0,0,0,0,	/* 12-bit lossy after split */
+      0x39,0x5a,0x38,0x27,0x16,5,4,3,2,1,0,11,12,12 ],
+    [ 0, 0,1,4,2,3,1,2,0,0,0,0,0,0,0,0,0,  /* 12-bit lossless */
+      5,4,6,3,7,2,8,1,9,0,10,11,12 ],
+    [ 0, 0,1,4,3,1,1,1,1,1,2,0,0,0,0,0,0,	/* 14-bit lossy */
+      5,6,4,7,8,3,9,2,1,0,10,11,12,13,14 ],
+    [ 0, 0,1,5,1,1,1,1,1,1,1,2,0,0,0,0,0,	/* 14-bit lossy after split */
+      8,0x5c,0x4b,0x3a,0x29,7,6,5,4,3,2,1,0,13,14 ],
+    [ 0, 0,1,4,2,2,3,1,2,0,0,0,0,0,0,0,0,	/* 14-bit lossless */
+      7,6,8,5,9,4,10,3,11,12,2,0,1,13,14 ] ];
+	  
+	var raw_width = img["t256"][0], height=img["t257"][0], tiff_bps=img["t258"][0];
+	
+	var tree = 0, split = 0;
+	var make_decoder = UTIF.decode._make_decoder;
+	var getbithuff   = UTIF.decode._getbithuff;
+	
+	var mn = imgs[0].exifIFD.makerNote, md = mn["t150"]?mn["t150"]:mn["t140"], mdo=0;  //console.log(mn,md);
+	//console.log(md[0].toString(16), md[1].toString(16), tiff_bps);
+	var ver0 = md[mdo++], ver1 = md[mdo++];
+	if (ver0 == 0x49 || ver1 == 0x58)  mdo+=2110;
+	if (ver0 == 0x46) tree = 2;
+	if (tiff_bps == 14) tree += 3;
+	
+	var vpred = [[0,0],[0,0]], bin=(img.isLE ? UTIF._binLE : UTIF._binBE);
+	for(var i=0; i<2; i++) for(var j=0; j<2; j++) {  vpred[i][j] = bin.readShort(md,mdo);  mdo+=2;   }  // not sure here ... [i][j] or [j][i]
+	//console.log(vpred);
+	
+	
+	var max = 1 << tiff_bps & 0x7fff, step=0;
+	var csize = bin.readShort(md,mdo);  mdo+=2;
+	if (csize > 1) step = Math.floor(max / (csize-1));
+	if (ver0 == 0x44 && ver1 == 0x20 && step > 0)  split = bin.readShort(md,562);
+	
+	
+	var i;
+	var row, col;
+	var len, shl, diff;
+	var min_v = 0;
+	var hpred = [0,0];
+	var huff = make_decoder(nikon_tree[tree]);
+	
+	//var g_input_offset=0, bitbuf=0, vbits=0, reset=0;
+	var prm = [off,0,0,0];
+	//console.log(split);  split = 170;
+	
+	for (min_v=row=0; row < height; row++) {
+		if (split && row == split) {
+			//free (huff);
+			huff = make_decoder (nikon_tree[tree+1]);
+			//max_v += (min_v = 16) << 1;
+		}
+		for (col=0; col < raw_width; col++) {
+			i = getbithuff(data,prm,huff[0],huff);
+			len = i  & 15;
+			shl = i >>> 4;
+			diff = (((getbithuff(data,prm,len-shl,0) << 1) + 1) << shl) >>> 1;
+			if ((diff & (1 << (len-1))) == 0)
+				diff -= (1 << len) - (shl==0?1:0);
+			if (col < 2) hpred[col] = vpred[row & 1][col] += diff;
+			else         hpred[col & 1] += diff;
+			
+			var clr = Math.min(Math.max(hpred[col & 1],0),(1<<tiff_bps)-1);
+			var bti = (row*raw_width+col)*tiff_bps;  
+			UTIF.decode._putsF(tgt, bti, clr<<(16-tiff_bps));
+		}
+	}
+}
+// put 16 bits
+UTIF.decode._putsF= function(dt, pos, val) {  val = val<<(8-(pos&7));  var o=(pos>>>3);  dt[o]|=val>>>16;  dt[o+1]|=val>>>8;  dt[o+2]|=val;  }
+
+
+UTIF.decode._getbithuff = function(data,prm,nbits, huff) {
+	var zero_after_ff = 0;
+	var get_byte = UTIF.decode._get_byte;
+	var c;
+  
+	var off=prm[0], bitbuf=prm[1], vbits=prm[2], reset=prm[3];
+
+	//if (nbits > 25) return 0;
+	//if (nbits <  0) return bitbuf = vbits = reset = 0;
+	if (nbits == 0 || vbits < 0) return 0; 
+	while (!reset && vbits < nbits && (c = data[off++]) != -1 &&
+		!(reset = zero_after_ff && c == 0xff && data[off++])) {
+		//console.log("byte read into c");
+		bitbuf = (bitbuf << 8) + c;
+		vbits += 8;
+	} 
+	c = (bitbuf << (32-vbits)) >>> (32-nbits);
+	if (huff) {
+		vbits -= huff[c+1] >>> 8;  //console.log(c, huff[c]>>8);
+		c =  huff[c+1]&255;
+	} else
+		vbits -= nbits;
+	if (vbits < 0) throw "e";
+  
+	prm[0]=off;  prm[1]=bitbuf;  prm[2]=vbits;  prm[3]=reset;
+  
+	return c;
+}
+
+UTIF.decode._make_decoder = function(source) {
+	var max, len, h, i, j;
+	var huff = [];
+
+	for (max=16; max!=0 && !source[max]; max--);
+	var si=17;
+	
+	huff[0] = max;
+	for (h=len=1; len <= max; len++)
+		for (i=0; i < source[len]; i++, ++si)
+			for (j=0; j < 1 << (max-len); j++)
+				if (h <= 1 << max)
+					huff[h++] = (len << 8) | source[si];
+	return huff;
+}
+
+UTIF.decode._decodeNewJPEG = function(img, data, off, len, tgt, toff)
+{
+	var tables = img["t347"], tlen = tables ? tables.length : 0, buff = new Uint8Array(tlen + len);
+	
+	if (tables) {
+		var SOI = 216, EOI = 217, boff = 0;
+		for (var i=0; i<(tlen-1); i++)
+		{
+			// Skip EOI marker from JPEGTables
+			if (tables[i]==255 && tables[i+1]==EOI) break;
+			buff[boff++] = tables[i];
+		}
+
+		// Skip SOI marker from data
+		var byte1 = data[off], byte2 = data[off + 1];
+		if (byte1!=255 || byte2!=SOI)
+		{
+			buff[boff++] = byte1;
+			buff[boff++] = byte2;
+		}
+		for (var i=2; i<len; i++) buff[boff++] = data[off+i];
+	}
+	else for (var i=0; i<len; i++) buff[i] = data[off+i];
+
+	if(img["t262"][0]==32803 || img["t262"][0]==34892) // lossless JPEG and lossy JPEG (used in DNG files)
+	{
+		var bps = img["t258"][0];//, dcdr = new LosslessJpegDecoder();
+		var out = UTIF.LosslessJpegDecode(buff), olen=out.length;  //console.log(olen);
+		
+		if(false) {}
+		else if(bps==16) {
+			if(img.isLE) for(var i=0; i<olen; i++ ) {  tgt[toff+(i<<1)] = (out[i]&255);  tgt[toff+(i<<1)+1] = (out[i]>>>8);  }
+			else         for(var i=0; i<olen; i++ ) {  tgt[toff+(i<<1)] = (out[i]>>>8);  tgt[toff+(i<<1)+1] = (out[i]&255);  }
+		}
+		else if(bps==14 || bps==12) {  // 4 * 14 == 56 == 7 * 8
+			var rst = 16-bps;
+			for(var i=0; i<olen; i++) UTIF.decode._putsF(tgt, i*bps, out[i]<<rst);
+		}
+		else throw new Error("unsupported bit depth "+bps);
+	}
+	else
+	{
+		var parser = new UTIF.JpegDecoder();  parser.parse(buff);
+		var decoded = parser.getData(parser.width, parser.height);
+		for (var i=0; i<decoded.length; i++) tgt[toff + i] = decoded[i];
+	}
+
+	// PhotometricInterpretation is 6 (YCbCr) for JPEG, but after decoding we populate data in
+	// RGB format, so updating the tag value
+	if(img["t262"][0] == 6)  img["t262"][0] = 2;
+}
+
+UTIF.decode._decodeOldJPEGInit = function(img, data, off, len)
+{
+	var SOI = 216, EOI = 217, DQT = 219, DHT = 196, DRI = 221, SOF0 = 192, SOS = 218;
+	var joff = 0, soff = 0, tables, sosMarker, isTiled = false, i, j, k;
+	var jpgIchgFmt    = img["t513"], jifoff = jpgIchgFmt ? jpgIchgFmt[0] : 0;
+	var jpgIchgFmtLen = img["t514"], jiflen = jpgIchgFmtLen ? jpgIchgFmtLen[0] : 0;
+	var soffTag       = img["t324"] || img["t273"] || jpgIchgFmt;
+	var ycbcrss       = img["t530"], ssx = 0, ssy = 0;
+	var spp           = img["t277"]?img["t277"][0]:1;
+	var jpgresint     = img["t515"];
+
+	if(soffTag)
+	{
+		soff = soffTag[0];
+		isTiled = (soffTag.length > 1);
+	}
+
+	if(!isTiled)
+	{
+		if(data[off]==255 && data[off+1]==SOI) return { jpegOffset: off };
+		if(jpgIchgFmt!=null)
+		{
+			if(data[off+jifoff]==255 && data[off+jifoff+1]==SOI) joff = off+jifoff;
+			else log("JPEGInterchangeFormat does not point to SOI");
+
+			if(jpgIchgFmtLen==null) log("JPEGInterchangeFormatLength field is missing");
+			else if(jifoff >= soff || (jifoff+jiflen) <= soff) log("JPEGInterchangeFormatLength field value is invalid");
+
+			if(joff != null) return { jpegOffset: joff };
+		}
+	}
+
+	if(ycbcrss!=null) {  ssx = ycbcrss[0];  ssy = ycbcrss[1];  }
+
+	if(jpgIchgFmt!=null)
+		if(jpgIchgFmtLen!=null)
+			if(jiflen >= 2 && (jifoff+jiflen) <= soff)
+			{
+				if(data[off+jifoff+jiflen-2]==255 && data[off+jifoff+jiflen-1]==SOI) tables = new Uint8Array(jiflen-2);
+				else tables = new Uint8Array(jiflen);
+
+				for(i=0; i<tables.length; i++) tables[i] = data[off+jifoff+i];
+				log("Incorrect JPEG interchange format: using JPEGInterchangeFormat offset to derive tables");
+			}
+			else log("JPEGInterchangeFormat+JPEGInterchangeFormatLength > offset to first strip or tile");
+
+	if(tables == null)
+	{
+		var ooff = 0, out = [];
+		out[ooff++] = 255; out[ooff++] = SOI;
+
+		var qtables = img["t519"];
+		if(qtables==null) throw new Error("JPEGQTables tag is missing");
+		for(i=0; i<qtables.length; i++)
+		{
+			out[ooff++] = 255; out[ooff++] = DQT; out[ooff++] = 0; out[ooff++] = 67; out[ooff++] = i;
+			for(j=0; j<64; j++) out[ooff++] = data[off+qtables[i]+j];
+		}
+
+		for(k=0; k<2; k++)
+		{
+			var htables = img[(k == 0) ? "t520" : "t521"];
+			if(htables==null) throw new Error(((k == 0) ? "JPEGDCTables" : "JPEGACTables") + " tag is missing");
+			for(i=0; i<htables.length; i++)
+			{
+				out[ooff++] = 255; out[ooff++] = DHT;
+				//out[ooff++] = 0; out[ooff++] = 67; out[ooff++] = i;
+				var nc = 19;
+				for(j=0; j<16; j++) nc += data[off+htables[i]+j];
+
+				out[ooff++] = (nc >>> 8); out[ooff++] = nc & 255;
+				out[ooff++] = (i | (k << 4));
+				for(j=0; j<16; j++) out[ooff++] = data[off+htables[i]+j];
+				for(j=0; j<nc; j++) out[ooff++] = data[off+htables[i]+16+j];
+			}
+		}
+
+		out[ooff++] = 255; out[ooff++] = SOF0;
+		out[ooff++] = 0;  out[ooff++] = 8 + 3*spp;  out[ooff++] = 8;
+		out[ooff++] = (img.height >>> 8) & 255;  out[ooff++] = img.height & 255;
+		out[ooff++] = (img.width  >>> 8) & 255;  out[ooff++] = img.width  & 255;
+		out[ooff++] = spp;
+		if(spp==1) {  out[ooff++] = 1;  out[ooff++] = 17;  out[ooff++] = 0;  }
+		else for(i=0; i<3; i++)
+		{
+			out[ooff++] = i + 1;
+			out[ooff++] = (i != 0) ? 17 : (((ssx & 15) << 4) | (ssy & 15));
+			out[ooff++] = i;
+		}
+
+		if(jpgresint!=null && jpgresint[0]!=0)
+		{
+			out[ooff++] = 255;  out[ooff++] = DRI;  out[ooff++] = 0;  out[ooff++] = 4;
+			out[ooff++] = (jpgresint[0] >>> 8) & 255;
+			out[ooff++] = jpgresint[0] & 255;
+		}
+
+		tables = new Uint8Array(out);
+	}
+
+	var sofpos = -1;
+	i = 0;
+	while(i < (tables.length - 1)) {
+		if(tables[i]==255 && tables[i+1]==SOF0) {  sofpos = i; break;  }
+		i++;
+	}
+
+	if(sofpos == -1)
+	{
+		var tmptab = new Uint8Array(tables.length + 10 + 3*spp);
+		tmptab.set(tables);
+		var tmpoff = tables.length;
+		sofpos = tables.length;
+		tables = tmptab;
+
+		tables[tmpoff++] = 255; tables[tmpoff++] = SOF0;
+		tables[tmpoff++] = 0;  tables[tmpoff++] = 8 + 3*spp;  tables[tmpoff++] = 8;
+		tables[tmpoff++] = (img.height >>> 8) & 255;  tables[tmpoff++] = img.height & 255;
+		tables[tmpoff++] = (img.width  >>> 8) & 255;  tables[tmpoff++] = img.width  & 255;
+		tables[tmpoff++] = spp;
+		if(spp==1) {  tables[tmpoff++] = 1;  tables[tmpoff++] = 17;  tables[tmpoff++] = 0;  }
+		else for(i=0; i<3; i++)
+		{
+			tables[tmpoff++] = i + 1;
+			tables[tmpoff++] = (i != 0) ? 17 : (((ssx & 15) << 4) | (ssy & 15));
+			tables[tmpoff++] = i;
+		}
+	}
+
+	if(data[soff]==255 && data[soff+1]==SOS)
+	{
+		var soslen = (data[soff+2]<<8) | data[soff+3];
+		sosMarker = new Uint8Array(soslen+2);
+		sosMarker[0] = data[soff];  sosMarker[1] = data[soff+1]; sosMarker[2] = data[soff+2];  sosMarker[3] = data[soff+3];
+		for(i=0; i<(soslen-2); i++) sosMarker[i+4] = data[soff+i+4];
+	}
+	else
+	{
+		sosMarker = new Uint8Array(2 + 6 + 2*spp);
+		var sosoff = 0;
+		sosMarker[sosoff++] = 255;  sosMarker[sosoff++] = SOS;
+		sosMarker[sosoff++] = 0;  sosMarker[sosoff++] = 6 + 2*spp;  sosMarker[sosoff++] = spp;
+		if(spp==1) {  sosMarker[sosoff++] = 1;  sosMarker[sosoff++] = 0;  }
+		else for(i=0; i<3; i++)
+		{
+			sosMarker[sosoff++] = i+1;  sosMarker[sosoff++] = (i << 4) | i;
+		}
+		sosMarker[sosoff++] = 0;  sosMarker[sosoff++] = 63;  sosMarker[sosoff++] = 0;
+	}
+
+	return { jpegOffset: off, tables: tables, sosMarker: sosMarker, sofPosition: sofpos };
+}
+
+UTIF.decode._decodeOldJPEG = function(img, data, off, len, tgt, toff)
+{
+	var i, dlen, tlen, buff, buffoff;
+	var jpegData = UTIF.decode._decodeOldJPEGInit(img, data, off, len);
+
+	if(jpegData.jpegOffset!=null)
+	{
+		dlen = off+len-jpegData.jpegOffset;
+		buff = new Uint8Array(dlen);
+		for(i=0; i<dlen; i++) buff[i] = data[jpegData.jpegOffset+i];
+	}
+	else
+	{
+		tlen = jpegData.tables.length;
+		buff = new Uint8Array(tlen + jpegData.sosMarker.length + len + 2);
+		buff.set(jpegData.tables);
+		buffoff = tlen;
+
+		buff[jpegData.sofPosition+5] = (img.height >>> 8) & 255;  buff[jpegData.sofPosition+6] = img.height & 255;
+		buff[jpegData.sofPosition+7] = (img.width  >>> 8) & 255;  buff[jpegData.sofPosition+8] = img.width  & 255;
+
+		if(data[off]!=255 || data[off+1]!=SOS)
+		{
+			buff.set(jpegData.sosMarker, buffoff);
+			buffoff += sosMarker.length;
+		}
+		for(i=0; i<len; i++) buff[buffoff++] = data[off+i];
+		buff[buffoff++] = 255;  buff[buffoff++] = EOI;
+	}
+
+	var parser = new UTIF.JpegDecoder();  parser.parse(buff);
+	var decoded = parser.getData(parser.width, parser.height);
+	for (var i=0; i<decoded.length; i++) tgt[toff + i] = decoded[i];
+
+	// PhotometricInterpretation is 6 (YCbCr) for JPEG, but after decoding we populate data in
+	// RGB format, so updating the tag value
+	if(img["t262"] && img["t262"][0] == 6)  img["t262"][0] = 2;
+}
+
+UTIF.decode._decodePackBits = function(data, off, len, tgt, toff)
+{
+	var sa = new Int8Array(data.buffer), ta = new Int8Array(tgt.buffer), lim = off+len;
+	while(off<lim)
+	{
+		var n = sa[off];  off++;
+		if(n>=0  && n<128)    for(var i=0; i< n+1; i++) {  ta[toff]=sa[off];  toff++;  off++;   }
+		if(n>=-127 && n<0) {  for(var i=0; i<-n+1; i++) {  ta[toff]=sa[off];  toff++;           }  off++;  }
+	}
+}
+
+UTIF.decode._decodeThunder = function(data, off, len, tgt, toff)
+{
+	var d2 = [ 0, 1, 0, -1 ],  d3 = [ 0, 1, 2, 3, 0, -3, -2, -1 ];
+	var lim = off+len, qoff = toff*2, px = 0;
+	while(off<lim)
+	{
+		var b = data[off], msk = (b>>>6), n = (b&63);  off++;
+		if(msk==3) { px=(n&15);  tgt[qoff>>>1] |= (px<<(4*(1-qoff&1)));  qoff++;   }
+		if(msk==0) for(var i=0; i<n; i++) {  tgt[qoff>>>1] |= (px<<(4*(1-qoff&1)));  qoff++;   }
+		if(msk==2) for(var i=0; i<2; i++) {  var d=(n>>>(3*(1-i)))&7;  if(d!=4) { px+=d3[d];  tgt[qoff>>>1] |= (px<<(4*(1-qoff&1)));  qoff++; }  }
+		if(msk==1) for(var i=0; i<3; i++) {  var d=(n>>>(2*(2-i)))&3;  if(d!=2) { px+=d2[d];  tgt[qoff>>>1] |= (px<<(4*(1-qoff&1)));  qoff++; }  }
+	}
+}
+
+UTIF.decode._dmap = { "1":0,"011":1,"000011":2,"0000011":3, "010":-1,"000010":-2,"0000010":-3  };
+UTIF.decode._lens = ( function()
+{
+	var addKeys = function(lens, arr, i0, inc) {  for(var i=0; i<arr.length; i++) lens[arr[i]] = i0 + i*inc;  }
+
+	var termW = "00110101,000111,0111,1000,1011,1100,1110,1111,10011,10100,00111,01000,001000,000011,110100,110101," // 15
+	+ "101010,101011,0100111,0001100,0001000,0010111,0000011,0000100,0101000,0101011,0010011,0100100,0011000,00000010,00000011,00011010," // 31
+	+ "00011011,00010010,00010011,00010100,00010101,00010110,00010111,00101000,00101001,00101010,00101011,00101100,00101101,00000100,00000101,00001010," // 47
+	+ "00001011,01010010,01010011,01010100,01010101,00100100,00100101,01011000,01011001,01011010,01011011,01001010,01001011,00110010,00110011,00110100";
+
+	var termB = "0000110111,010,11,10,011,0011,0010,00011,000101,000100,0000100,0000101,0000111,00000100,00000111,000011000," // 15
+	+ "0000010111,0000011000,0000001000,00001100111,00001101000,00001101100,00000110111,00000101000,00000010111,00000011000,000011001010,000011001011,000011001100,000011001101,000001101000,000001101001," // 31
+	+ "000001101010,000001101011,000011010010,000011010011,000011010100,000011010101,000011010110,000011010111,000001101100,000001101101,000011011010,000011011011,000001010100,000001010101,000001010110,000001010111," // 47
+	+ "000001100100,000001100101,000001010010,000001010011,000000100100,000000110111,000000111000,000000100111,000000101000,000001011000,000001011001,000000101011,000000101100,000001011010,000001100110,000001100111";
+
+	var makeW = "11011,10010,010111,0110111,00110110,00110111,01100100,01100101,01101000,01100111,011001100,011001101,011010010,011010011,011010100,011010101,011010110,"
+	+ "011010111,011011000,011011001,011011010,011011011,010011000,010011001,010011010,011000,010011011";
+
+	var makeB = "0000001111,000011001000,000011001001,000001011011,000000110011,000000110100,000000110101,0000001101100,0000001101101,0000001001010,0000001001011,0000001001100,"
+	+ "0000001001101,0000001110010,0000001110011,0000001110100,0000001110101,0000001110110,0000001110111,0000001010010,0000001010011,0000001010100,0000001010101,0000001011010,"
+	+ "0000001011011,0000001100100,0000001100101";
+
+	var makeA = "00000001000,00000001100,00000001101,000000010010,000000010011,000000010100,000000010101,000000010110,000000010111,000000011100,000000011101,000000011110,000000011111";
+
+	termW = termW.split(",");  termB = termB.split(",");  makeW = makeW.split(",");  makeB = makeB.split(",");  makeA = makeA.split(",");
+
+	var lensW = {}, lensB = {};
+	addKeys(lensW, termW, 0, 1);  addKeys(lensW, makeW, 64,64);  addKeys(lensW, makeA, 1792,64);
+	addKeys(lensB, termB, 0, 1);  addKeys(lensB, makeB, 64,64);  addKeys(lensB, makeA, 1792,64);
+	return [lensW, lensB];
+} )();
+
+UTIF.decode._decodeG4 = function(data, off, slen, tgt, toff, w, fo)
+{
+	var U = UTIF.decode, boff=off<<3, len=0, wrd="";	// previous starts with 1
+	var line=[], pline=[];  for(var i=0; i<w; i++) pline.push(0);  pline=U._makeDiff(pline);
+	var a0=0, a1=0, a2=0, b1=0, b2=0, clr=0;
+	var y=0, mode="", toRead=0;
+	var bipl = Math.ceil(w/8)*8;
+
+	while((boff>>>3)<off+slen)
+	{
+		b1 = U._findDiff(pline, a0+(a0==0?0:1), 1-clr), b2 = U._findDiff(pline, b1, clr);	// could be precomputed
+		var bit =0;
+		if(fo==1) bit = (data[boff>>>3]>>>(7-(boff&7)))&1;
+		if(fo==2) bit = (data[boff>>>3]>>>(  (boff&7)))&1;
+		boff++;  wrd+=bit;
+		if(mode=="H")
+		{
+			if(U._lens[clr][wrd]!=null)
+			{
+				var dl=U._lens[clr][wrd];  wrd="";  len+=dl;
+				if(dl<64) {  U._addNtimes(line,len,clr);  a0+=len;  clr=1-clr;  len=0;  toRead--;  if(toRead==0) mode="";  }
+			}
+		}
+		else
+		{
+			if(wrd=="0001")  {  wrd="";  U._addNtimes(line,b2-a0,clr);  a0=b2;   }
+			if(wrd=="001" )  {  wrd="";  mode="H";  toRead=2;  }
+			if(U._dmap[wrd]!=null) {  a1 = b1+U._dmap[wrd];  U._addNtimes(line, a1-a0, clr);  a0=a1;  wrd="";  clr=1-clr;  }
+		}
+		if(line.length==w && mode=="")
+		{
+			U._writeBits(line, tgt, toff*8+y*bipl);
+			clr=0;  y++;  a0=0;
+			pline=U._makeDiff(line);  line=[];
+		}
+		//if(wrd.length>150) {  log(wrd);  break;  throw "e";  }
+	}
+}
+
+UTIF.decode._findDiff = function(line, x, clr) {  for(var i=0; i<line.length; i+=2) if(line[i]>=x && line[i+1]==clr)  return line[i];  }
+
+UTIF.decode._makeDiff = function(line)
+{
+	var out = [];  if(line[0]==1) out.push(0,1);
+	for(var i=1; i<line.length; i++) if(line[i-1]!=line[i]) out.push(i, line[i]);
+	out.push(line.length,0,line.length,1);  return out;
+}
+
+UTIF.decode._decodeG3 = function(data, off, slen, tgt, toff, w, fo)
+{
+	var U = UTIF.decode, boff=off<<3, len=0, wrd="";
+	var line=[], pline=[];  for(var i=0; i<w; i++) line.push(0);
+	var a0=0, a1=0, a2=0, b1=0, b2=0, clr=0;
+	var y=-1, mode="", toRead=0, is1D=false;
+	var bipl = Math.ceil(w/8)*8;
+	while((boff>>>3)<off+slen)
+	{
+		b1 = U._findDiff(pline, a0+(a0==0?0:1), 1-clr), b2 = U._findDiff(pline, b1, clr);	// could be precomputed
+		var bit =0;
+		if(fo==1) bit = (data[boff>>>3]>>>(7-(boff&7)))&1;
+		if(fo==2) bit = (data[boff>>>3]>>>(  (boff&7)))&1;
+		boff++;  wrd+=bit;
+
+		if(is1D)
+		{
+			if(U._lens[clr][wrd]!=null)
+			{
+				var dl=U._lens[clr][wrd];  wrd="";  len+=dl;
+				if(dl<64) {  U._addNtimes(line,len,clr);  clr=1-clr;  len=0;  }
+			}
+		}
+		else
+		{
+			if(mode=="H")
+			{
+				if(U._lens[clr][wrd]!=null)
+				{
+					var dl=U._lens[clr][wrd];  wrd="";  len+=dl;
+					if(dl<64) {  U._addNtimes(line,len,clr);  a0+=len;  clr=1-clr;  len=0;  toRead--;  if(toRead==0) mode="";  }
+				}
+			}
+			else
+			{
+				if(wrd=="0001")  {  wrd="";  U._addNtimes(line,b2-a0,clr);  a0=b2;   }
+				if(wrd=="001" )  {  wrd="";  mode="H";  toRead=2;  }
+				if(U._dmap[wrd]!=null) {  a1 = b1+U._dmap[wrd];  U._addNtimes(line, a1-a0, clr);  a0=a1;  wrd="";  clr=1-clr;  }
+			}
+		}
+		if(wrd.endsWith("000000000001")) // needed for some files
+		{
+			if(y>=0) U._writeBits(line, tgt, toff*8+y*bipl);
+			if(fo==1) is1D = ((data[boff>>>3]>>>(7-(boff&7)))&1)==1;
+			if(fo==2) is1D = ((data[boff>>>3]>>>(  (boff&7)))&1)==1;
+			boff++;
+			if(U._decodeG3.allow2D==null) U._decodeG3.allow2D=is1D;
+			if(!U._decodeG3.allow2D) {  is1D = true;  boff--;  }
+			//log("EOL",y, "next 1D:", is1D);
+			wrd="";  clr=0;  y++;  a0=0;
+			pline=U._makeDiff(line);  line=[];
+		}
+	}
+	if(line.length==w) U._writeBits(line, tgt, toff*8+y*bipl);
+}
+
+UTIF.decode._addNtimes = function(arr, n, val) {  for(var i=0; i<n; i++) arr.push(val);  }
+
+UTIF.decode._writeBits = function(bits, tgt, boff)
+{
+	for(var i=0; i<bits.length; i++) tgt[(boff+i)>>>3] |= (bits[i]<<(7-((boff+i)&7)));
+}
+
+UTIF.decode._decodeLZW = function(data, off, tgt, toff)
+{
+	if(UTIF.decode._lzwTab==null)
+	{
+		var tb=new Uint32Array(0xffff), tn=new Uint16Array(0xffff), chr=new Uint8Array(2e6);
+		for(var i=0; i<256; i++) { chr[i<<2]=i;  tb[i]=i<<2;  tn[i]=1;  }
+		UTIF.decode._lzwTab = [tb,tn,chr];
+	}
+	var copy = UTIF.decode._copyData;
+	var tab = UTIF.decode._lzwTab[0], tln=UTIF.decode._lzwTab[1], chr=UTIF.decode._lzwTab[2], totl = 258, chrl = 258<<2;
+	var bits = 9, boff = off<<3;  // offset in bits
+
+	var ClearCode = 256, EoiCode = 257;
+	var v = 0, Code = 0, OldCode = 0;
+	while(true)
+	{
+		v = (data[boff>>>3]<<16) | (data[(boff+8)>>>3]<<8) | data[(boff+16)>>>3];
+		Code = ( v>>(24-(boff&7)-bits) )    &   ((1<<bits)-1);  boff+=bits;
+
+		if(Code==EoiCode) break;
+		if(Code==ClearCode)
+		{
+			bits=9;  totl = 258;  chrl = 258<<2;
+
+			v = (data[boff>>>3]<<16) | (data[(boff+8)>>>3]<<8) | data[(boff+16)>>>3];
+			Code = ( v>>(24-(boff&7)-bits) )    &   ((1<<bits)-1);  boff+=bits;
+			if(Code==EoiCode) break;
+			tgt[toff]=Code;  toff++;
+		}
+		else if(Code<totl)
+		{
+			var cd = tab[Code], cl = tln[Code];
+			copy(chr,cd,tgt,toff,cl);  toff += cl;
+
+			if(OldCode>=totl) {  tab[totl] = chrl;  chr[tab[totl]] = cd[0];  tln[totl]=1;  chrl=(chrl+1+3)&~0x03;  totl++;  }
+			else
+			{
+				tab[totl] = chrl;
+				var nit = tab[OldCode], nil = tln[OldCode];
+				copy(chr,nit,chr,chrl,nil);
+				chr[chrl+nil]=chr[cd];  nil++;
+				tln[totl]=nil;  totl++;
+
+				chrl=(chrl+nil+3)&~0x03;
+			}
+			if(totl+1==(1<<bits)) bits++;
+		}
+		else
+		{
+			if(OldCode>=totl) {  tab[totl] = chrl;  tln[totl]=0;  totl++;  }
+			else
+			{
+				tab[totl] = chrl;
+				var nit = tab[OldCode], nil = tln[OldCode];
+				copy(chr,nit,chr,chrl,nil);
+				chr[chrl+nil]=chr[chrl];  nil++;
+				tln[totl]=nil;  totl++;
+
+				copy(chr,chrl,tgt,toff,nil);  toff += nil;
+				chrl=(chrl+nil+3)&~0x03;
+			}
+			if(totl+1==(1<<bits)) bits++;
+		}
+		OldCode = Code;
+	}
+}
+
+UTIF.decode._copyData = function(s,so,t,to,l) {  for(var i=0;i<l;i+=4) {  t[to+i]=s[so+i];  t[to+i+1]=s[so+i+1];  t[to+i+2]=s[so+i+2];  t[to+i+3]=s[so+i+3];  }  }
+
+UTIF.tags = {};
+UTIF.ttypes = {  256:3,257:3,258:3,   259:3, 262:3,  273:4,  274:3, 277:3,278:4,279:4, 282:5, 283:5, 284:3, 286:5,287:5, 296:3, 305:2, 306:2, 338:3, 513:4, 514:4, 34665:4  };
+
+UTIF._readIFD = function(bin, data, offset, ifds, depth, debug)
+{
+	var cnt = bin.readUshort(data, offset);  offset+=2;
+	var ifd = {};  ifds.push(ifd);
+
+	if(debug) log("   ".repeat(depth),ifds.length-1,">>>----------------");
+	for(var i=0; i<cnt; i++)
+	{
+		var tag  = bin.readUshort(data, offset);    offset+=2;
+		var type = bin.readUshort(data, offset);    offset+=2;
+		var num  = bin.readUint  (data, offset);    offset+=4;
+		var voff = bin.readUint  (data, offset);    offset+=4;
+		//if(tag==33723) {type=1; num*=4;}//console.log(type,num,voff);//type = 1;  // IPTC/NAA
+
+		var arr = [];
+		//ifd["t"+tag+"-"+UTIF.tags[tag]] = arr;
+		if(type== 1 || type==7) {  arr = new Uint8Array(data.buffer, (num<5 ? offset-4 : voff), num);  }
+		if(type== 2) {  var o0 = (num<5 ? offset-4 : voff), c=data[o0];  
+						if(c<128) arr.push( bin.readASCII(data, o0, num-1) );
+						else      arr = new Uint8Array(data.buffer, o0, num-1);  }
+		if(type== 3) {  for(var j=0; j<num; j++) arr.push(bin.readUshort(data, (num<3 ? offset-4 : voff)+2*j));  }
+		if(type== 4) {  for(var j=0; j<num; j++) arr.push(bin.readUint  (data, (num<2 ? offset-4 : voff)+4*j));  }
+		if(type== 5) {  for(var j=0; j<num; j++) arr.push(bin.readUint  (data, voff+j*8) / bin.readUint(data,voff+j*8+4));  }
+		if(type== 8) {  for(var j=0; j<num; j++) arr.push(bin.readShort (data, (num<3 ? offset-4 : voff)+2*j));  }
+		if(type== 9) {  for(var j=0; j<num; j++) arr.push(bin.readInt   (data, (num<2 ? offset-4 : voff)+4*j));  }
+		if(type==10) {  for(var j=0; j<num; j++) arr.push(bin.readInt   (data, voff+j*8) / bin.readInt (data,voff+j*8+4));  }
+		if(type==11) {  for(var j=0; j<num; j++) arr.push(bin.readFloat (data, voff+j*4));  }
+		if(type==12) {  for(var j=0; j<num; j++) arr.push(bin.readDouble(data, voff+j*8));  }
+		
+		ifd["t"+tag] = arr;
+		
+		if(num!=0 && arr.length==0) {  log("unknown TIFF tag type: ", type, "num:",num);  }
+		if(debug) log("   ".repeat(depth), tag, type, UTIF.tags[tag], arr);
+		
+		if(tag==330 && ifd["t272"] && ifd["t272"][0]=="DSLR-A100") {  } 
+		// ifd["t258"]=[12];  ifd["t259"]=[32767];  ifd["t273"]=[offset+arr[0]];  ifd["t277"]=[1];  ifd["t279"]=[1];  ifd["t33421"]=[2,2];  ifd["t33422"]=[0,1,1,2];
+		else if(tag==330 || tag==34665 || (tag==50740 && bin.readUshort(data,bin.readUint(arr,0))<300  )) {
+			var oarr = tag==50740 ? [bin.readUint(arr,0)] : arr;
+			var subfd = [];
+			for(var j=0; j<oarr.length; j++) UTIF._readIFD(bin, data, oarr[j], subfd, depth+1, debug);
+			if(tag==  330) ifd.subIFD = subfd;
+			if(tag==34665) ifd.exifIFD = subfd[0];
+			if(tag==50740) ifd.dngPrvt = subfd[0];
+		}
+		if(tag==37500) {
+			var mn = arr;
+			//console.log(bin.readASCII(mn,0,mn.length), mn);
+			if(bin.readASCII(mn,0,5)=="Nikon")  ifd.makerNote = UTIF["decode"](mn.slice(10).buffer)[0];
+			else if(bin.readUshort(data,voff)<300){
+				var subsub=[];  UTIF._readIFD(bin, data, voff, subsub, depth+1, debug);
+				ifd.makerNote = subsub[0];
+			}
+		}
+	}
+	if(debug) log("   ".repeat(depth),"<<<---------------");
+	return offset;
+}
+
+UTIF._writeIFD = function(bin, data, offset, ifd)
+{
+	var keys = Object.keys(ifd);
+	bin.writeUshort(data, offset, keys.length);  offset+=2;
+
+	var eoff = offset + keys.length*12 + 4;
+
+	for(var ki=0; ki<keys.length; ki++)
+	{
+		var key = keys[ki];
+		var tag = parseInt(key.slice(1)), type = UTIF.ttypes[tag];  if(type==null) throw new Error("unknown type of tag: "+tag);
+		var val = ifd[key];  if(type==2) val=val[0]+"\u0000";  var num = val.length;
+		bin.writeUshort(data, offset, tag );  offset+=2;
+		bin.writeUshort(data, offset, type);  offset+=2;
+		bin.writeUint  (data, offset, num );  offset+=4;
+
+		var dlen = [-1, 1, 1, 2, 4, 8, 0, 0, 0, 0, 0, 0, 8][type] * num;
+		var toff = offset;
+		if(dlen>4) {  bin.writeUint(data, offset, eoff);  toff=eoff;  }
+
+		if(type==2) {  bin.writeASCII(data, toff, val);   }
+		if(type==3) {  for(var i=0; i<num; i++) bin.writeUshort(data, toff+2*i, val[i]);    }
+		if(type==4) {  for(var i=0; i<num; i++) bin.writeUint  (data, toff+4*i, val[i]);    }
+		if(type==5) {  for(var i=0; i<num; i++) {  bin.writeUint(data, toff+8*i, Math.round(val[i]*10000));  bin.writeUint(data, toff+8*i+4, 10000);  }   }
+		if (type == 12) {  for (var i = 0; i < num; i++) bin.writeDouble(data, toff + 8 * i, val[i]); }
+
+		if(dlen>4) {  dlen += (dlen&1);  eoff += dlen;  }
+		offset += 4;
+	}
+	return [offset, eoff];
+}
+
+UTIF.toRGBA8 = function(out)
+{
+	var w = out.width, h = out.height, area = w*h, qarea = area*4, data = out.data;
+	var img = new Uint8Array(area*4);
+	//console.log(out);
+	// 0: WhiteIsZero, 1: BlackIsZero, 2: RGB, 3: Palette color, 4: Transparency mask, 5: CMYK
+	var intp = (out["t262"] ? out["t262"][0]: 2), bps = (out["t258"]?Math.min(32,out["t258"][0]):1);
+	//log("interpretation: ", intp, "bps", bps, out);
+	if(false) {}
+	else if(intp==0)
+	{
+		var bpl = Math.ceil(bps*w/8);
+		for(var y=0; y<h; y++) {
+			var off = y*bpl, io = y*w;
+			if(bps== 1) for(var i=0; i<w; i++) {  var qi=(io+i)<<2, px=((data[off+(i>>3)])>>(7-  (i&7)))& 1;  img[qi]=img[qi+1]=img[qi+2]=( 1-px)*255;  img[qi+3]=255;    }
+			if(bps== 4) for(var i=0; i<w; i++) {  var qi=(io+i)<<2, px=((data[off+(i>>1)])>>(4-4*(i&1)))&15;  img[qi]=img[qi+1]=img[qi+2]=(15-px)* 17;  img[qi+3]=255;    }
+			if(bps== 8) for(var i=0; i<w; i++) {  var qi=(io+i)<<2, px=data[off+i];  img[qi]=img[qi+1]=img[qi+2]=255-px;  img[qi+3]=255;    }
+		}
+	}
+	else if(intp==1)
+	{
+		var bpl = Math.ceil(bps*w/8);
+		for(var y=0; y<h; y++) {
+			var off = y*bpl, io = y*w;
+			if(bps== 1) for(var i=0; i<w; i++) {  var qi=(io+i)<<2, px=((data[off+(i>>3)])>>(7-  (i&7)))&1;   img[qi]=img[qi+1]=img[qi+2]=(px)*255;  img[qi+3]=255;    }
+			if(bps== 2) for(var i=0; i<w; i++) {  var qi=(io+i)<<2, px=((data[off+(i>>2)])>>(6-2*(i&3)))&3;   img[qi]=img[qi+1]=img[qi+2]=(px)* 85;  img[qi+3]=255;    }
+			if(bps== 8) for(var i=0; i<w; i++) {  var qi=(io+i)<<2, px=data[off+i];  img[qi]=img[qi+1]=img[qi+2]=    px;  img[qi+3]=255;    }
+			if(bps==16) for(var i=0; i<w; i++) {  var qi=(io+i)<<2, px=data[off+(2*i+1)];  img[qi]=img[qi+1]=img[qi+2]= Math.min(255,px);  img[qi+3]=255;    } // ladoga.tif
+		}
+	}
+	else if(intp==2)
+	{
+		var smpls = out["t258"]?out["t258"].length : 3;
+		
+		if(bps== 8) 
+		{
+			if(smpls==4) for(var i=0; i<qarea; i++) img[i] = data[i];
+			if(smpls==3) for(var i=0; i< area; i++) {  var qi=i<<2, ti=i*3;  img[qi]=data[ti];  img[qi+1]=data[ti+1];  img[qi+2]=data[ti+2];  img[qi+3]=255;    }
+		}
+		else{  // 3x 16-bit channel
+			if(smpls==4) for(var i=0; i<area; i++) {  var qi=i<<2, ti=i*8+1;  img[qi]=data[ti];  img[qi+1]=data[ti+2];  img[qi+2]=data[ti+4];  img[qi+3]=data[ti+6];    }
+			if(smpls==3) for(var i=0; i<area; i++) {  var qi=i<<2, ti=i*6+1;  img[qi]=data[ti];  img[qi+1]=data[ti+2];  img[qi+2]=data[ti+4];  img[qi+3]=255;           }
+		}
+	}
+	else if(intp==3)
+	{
+		var map = out["t320"];
+		for(var i=0; i<area; i++) {  var qi=i<<2, mi=data[i];  img[qi]=(map[mi]>>8);  img[qi+1]=(map[256+mi]>>8);  img[qi+2]=(map[512+mi]>>8);  img[qi+3]=255;    }
+	}
+	else if(intp==5) 
+	{
+		var smpls = out["t258"]?out["t258"].length : 4;
+		var gotAlpha = smpls>4 ? 1 : 0;
+		for(var i=0; i<area; i++) {
+			var qi=i<<2, si=i*smpls;  var C=255-data[si], M=255-data[si+1], Y=255-data[si+2], K=(255-data[si+3])*(1/255);
+			img[qi]=~~(C*K+0.5);  img[qi+1]=~~(M*K+0.5);  img[qi+2]=~~(Y*K+0.5);  img[qi+3]=255*(1-gotAlpha)+data[si+4]*gotAlpha;
+		}
+	}
+	else log("Unknown Photometric interpretation: "+intp);
+	return img;
+}
+
+UTIF.replaceIMG = function(imgs)
+{
+	if(imgs==null) imgs = document.getElementsByTagName("img");
+	var sufs = ["tif","tiff","dng","cr2","nef"]
+	for (var i=0; i<imgs.length; i++)
+	{
+		var img=imgs[i], src=img.getAttribute("src");  if(src==null) continue;
+		var suff=src.split(".").pop().toLowerCase();
+		if(sufs.indexOf(suff)==-1) continue;
+		var xhr = new XMLHttpRequest();  UTIF._xhrs.push(xhr);  UTIF._imgs.push(img);
+		xhr.open("GET", src);  xhr.responseType = "arraybuffer";
+		xhr.onload = UTIF._imgLoaded;   xhr.send();
+	}
+}
+
+UTIF._xhrs = [];  UTIF._imgs = [];
+UTIF._imgLoaded = function(e)
+{
+	var buff = e.target.response;
+	var ifds = UTIF.decode(buff);  //console.log(ifds);
+	var vsns = ifds, ma=0, page=vsns[0];  if(ifds[0].subIFD) vsns = vsns.concat(ifds[0].subIFD);
+	for(var i=0; i<vsns.length; i++) {
+		var img = vsns[i];
+		if(img["t258"]==null || img["t258"].length<3) continue;
+		var ar = img["t256"]*img["t257"];
+		if(ar>ma) {  ma=ar;  page=img;  }
+	}
+	UTIF.decodeImage(buff, page, ifds);
+	var rgba = UTIF.toRGBA8(page), w=page.width, h=page.height;
+	var ind = UTIF._xhrs.indexOf(e.target), img = UTIF._imgs[ind];
+	UTIF._xhrs.splice(ind,1);  UTIF._imgs.splice(ind,1);
+	var cnv = document.createElement("canvas");  cnv.width=w;  cnv.height=h;
+	var ctx = cnv.getContext("2d"), imgd = ctx.createImageData(w,h);
+	for(var i=0; i<rgba.length; i++) imgd.data[i]=rgba[i];       ctx.putImageData(imgd,0,0);
+	img.setAttribute("src",cnv.toDataURL());
+}
+
+
+UTIF._binBE =
+{
+	nextZero   : function(data, o) {  while(data[o]!=0) o++;  return o;  },
+	readUshort : function(buff, p) {  return (buff[p]<< 8) |  buff[p+1];  },
+	readShort  : function(buff, p) {  var a=UTIF._binBE.ui8;  a[0]=buff[p+1];  a[1]=buff[p+0];                                    return UTIF._binBE. i16[0];  },
+	readInt    : function(buff, p) {  var a=UTIF._binBE.ui8;  a[0]=buff[p+3];  a[1]=buff[p+2];  a[2]=buff[p+1];  a[3]=buff[p+0];  return UTIF._binBE. i32[0];  },
+	readUint   : function(buff, p) {  var a=UTIF._binBE.ui8;  a[0]=buff[p+3];  a[1]=buff[p+2];  a[2]=buff[p+1];  a[3]=buff[p+0];  return UTIF._binBE.ui32[0];  },
+	readASCII  : function(buff, p, l) {  var s = "";   for(var i=0; i<l; i++) s += String.fromCharCode(buff[p+i]);   return s; },
+	readFloat  : function(buff, p) {  var a=UTIF._binBE.ui8;  for(var i=0;i<4;i++) a[i]=buff[p+3-i];  return UTIF._binBE.fl32[0];  },
+	readDouble : function(buff, p) {  var a=UTIF._binBE.ui8;  for(var i=0;i<8;i++) a[i]=buff[p+7-i];  return UTIF._binBE.fl64[0];  },
+
+	writeUshort: function(buff, p, n) {  buff[p] = (n>> 8)&255;  buff[p+1] =  n&255;  },
+	writeUint  : function(buff, p, n) {  buff[p] = (n>>24)&255;  buff[p+1] = (n>>16)&255;  buff[p+2] = (n>>8)&255;  buff[p+3] = (n>>0)&255;  },
+	writeASCII : function(buff, p, s) {  for(var i = 0; i < s.length; i++)  buff[p+i] = s.charCodeAt(i);  },
+	writeDouble: function(buff, p, n)
+	{
+		UTIF._binBE.fl64[0] = n;
+		for (var i = 0; i < 8; i++) buff[p + i] = UTIF._binBE.ui8[7 - i];
+	}
+}
+UTIF._binBE.ui8  = new Uint8Array  (8);
+UTIF._binBE.i16  = new Int16Array  (UTIF._binBE.ui8.buffer);
+UTIF._binBE.i32  = new Int32Array  (UTIF._binBE.ui8.buffer);
+UTIF._binBE.ui32 = new Uint32Array (UTIF._binBE.ui8.buffer);
+UTIF._binBE.fl32 = new Float32Array(UTIF._binBE.ui8.buffer);
+UTIF._binBE.fl64 = new Float64Array(UTIF._binBE.ui8.buffer);
+
+UTIF._binLE =
+{
+	nextZero   : UTIF._binBE.nextZero,
+	readUshort : function(buff, p) {  return (buff[p+1]<< 8) |  buff[p];  },
+	readShort  : function(buff, p) {  var a=UTIF._binBE.ui8;  a[0]=buff[p+0];  a[1]=buff[p+1];                                    return UTIF._binBE. i16[0];  },
+	readInt    : function(buff, p) {  var a=UTIF._binBE.ui8;  a[0]=buff[p+0];  a[1]=buff[p+1];  a[2]=buff[p+2];  a[3]=buff[p+3];  return UTIF._binBE. i32[0];  },
+	readUint   : function(buff, p) {  var a=UTIF._binBE.ui8;  a[0]=buff[p+0];  a[1]=buff[p+1];  a[2]=buff[p+2];  a[3]=buff[p+3];  return UTIF._binBE.ui32[0];  },
+	readASCII  : UTIF._binBE.readASCII,
+	readFloat  : function(buff, p) {  var a=UTIF._binBE.ui8;  for(var i=0;i<4;i++) a[i]=buff[p+  i];  return UTIF._binBE.fl32[0];  },
+	readDouble : function(buff, p) {  var a=UTIF._binBE.ui8;  for(var i=0;i<8;i++) a[i]=buff[p+  i];  return UTIF._binBE.fl64[0];  }
+}
+UTIF._copyTile = function(tb, tw, th, b, w, h, xoff, yoff)
+{
+	//log("copyTile", tw, th,  w, h, xoff, yoff);
+	var xlim = Math.min(tw, w-xoff);
+	var ylim = Math.min(th, h-yoff);
+	for(var y=0; y<ylim; y++)
+	{
+		var tof = (yoff+y)*w+xoff;
+		var sof = y*tw;
+		for(var x=0; x<xlim; x++) b[tof+x] = tb[sof+x];
+	}
+}
+
+UTIF.LosslessJpegDecode = (function(){function t(Z){this.w=Z;this.N=0;this._=0;this.G=0}t.prototype={t:function(Z){this.N=Math.max(0,Math.min(this.w.length,Z))},i:function(){return this.w[this.N++]},l:function(){var Z=this.N;
+this.N+=2;return this.w[Z]<<8|this.w[Z+1]},J:function(){if(this._==0){this.G=this.w[this.N];this.N+=1+(this.G+1>>>8);
+this._=8}return this.G>>>--this._&1},Z:function(Z){var X=this._,s=this.G,E=Math.min(X,Z);Z-=E;X-=E;var Y=s>>>X&(1<<E)-1;
+while(Z>0){s=this.w[this.N];this.N+=1+(s+1>>>8);E=Math.min(8,Z);Z-=E;X=8-E;Y<<=E;Y|=s>>>X&(1<<E)-1}this._=X;
+this.G=s;return Y}};var i={};i.X=function(){return[0,0,-1]};i.s=function(Z,X,s){Z[i.Y(Z,0,s)+2]=X};i.Y=function(Z,X,s){if(Z[X+2]!=-1)return 0;
+if(s==0)return X;for(var E=0;E<2;E++){if(Z[X+E]==0){Z[X+E]=Z.length;Z.push(0);Z.push(0);Z.push(-1)}var Y=i.Y(Z,Z[X+E],s-1);
+if(Y!=0)return Y}return 0};i.B=function(Z,X){var s=0,E=0,Y=0,B=X._,$=X.G,e=X.N;while(!0){if(B==0){$=X.w[e];
+e+=1+($+1>>>8);B=8}Y=$>>>--B&1;s=Z[s+Y];E=Z[s+2];if(E!=-1){X._=B;X.G=$;X.N=e;return E}}return-1};function l(Z){this.z=new t(Z);
+this.D(this.z)}l.prototype={$:function(Z,X){this.Q=Z.i();this.F=Z.l();this.o=Z.l();var s=this.O=Z.i();
+this.L=[];for(var E=0;E<s;E++){var Y=Z.i(),B=Z.i();Z.i();this.L[Y]=E}Z.t(Z.N+X-(6+s*3))},e:function(){var Z=0,X=this.z.i();
+if(this.H==null)this.H={};var s=this.H[X]=i.X(),E=[];for(var Y=0;Y<16;Y++){E[Y]=this.z.i();Z+=E[Y]}for(var Y=0;
+Y<16;Y++)for(var B=0;B<E[Y];B++)i.s(s,this.z.i(),Y+1);return Z+17},W:function(Z){while(Z>0)Z-=this.e()},p:function(Z,X){var s=Z.i();
+if(!this.U){this.U=[]}for(var E=0;E<s;E++){var Y=Z.i(),B=Z.i();this.U[this.L[Y]]=this.H[B>>>4]}this.g=Z.i();
+Z.t(Z.N+X-(2+s*2))},D:function(Z){var X=!1,s=Z.l();if(s!==l.q)return;do{var s=Z.l(),E=Z.l()-2;switch(s){case l.m:this.$(Z,E);
+break;case l.K:this.W(E);break;case l.V:this.p(Z,E);X=!0;break;default:Z.t(Z.N+E);break}}while(!X)},I:function(Z,X){var s=i.B(X,Z);
+if(s==16)return-32768;var E=Z.Z(s);if((E&1<<s-1)==0)E-=(1<<s)-1;return E},B:function(Z,X){var s=this.z,E=this.O,Y=this.F,B=this.I,$=this.g,e=this.o*E,W=this.U;
+for(var p=0;p<E;p++){Z[p]=B(s,W[p])+(1<<this.Q-1)}for(var D=E;D<e;D+=E){for(var p=0;p<E;p++)Z[D+p]=B(s,W[p])+Z[D+p-E]}var I=X;
+for(var m=1;m<Y;m++){for(var p=0;p<E;p++){Z[I+p]=B(s,W[p])+Z[I+p-X]}for(var D=E;D<e;D+=E){for(var p=0;
+p<E;p++){var K=I+D+p,q=Z[K-E];if($==6)q=Z[K-X]+(q-Z[K-E-X]>>>1);Z[K]=q+B(s,W[p])}}I+=X}}};l.m=65475;
+l.K=65476;l.q=65496;l.V=65498;function J(Z){var X=new l(Z),s=X.Q>8?Uint16Array:Uint8Array,E=new s(X.o*X.F*X.O),Y=X.o*X.O;
+X.B(E,Y);return E}return J}())
+
+
+
+
+})(UTIF, pako);
+})();

+ 91 - 22
src/App.vue

@@ -1,11 +1,37 @@
 <template>
   <template v-if="slides.length">
-    <Screen v-if="viewMode !== 'student'"  v-show="screening"/>
-    <Editor v-if="viewMode !== 'student'" 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 />
+    <Screen v-if="viewMode !== 'student' && screening" key="screen" />
+    <KeepAlive>
+      <Editor
+        v-if="viewMode === 'editor' && _isPC && !screening"
+        :courseid="urlParams.courseid"
+        key="editor"
+      />
+      <Editor2
+        v-else-if="viewMode === 'editor2' && _isPC && !screening"
+        :courseid="urlParams.courseid"
+        key="editor2"
+      />
+      <Editor3
+        v-else-if="viewMode === 'editor3' && _isPC && !screening"
+        :courseid="urlParams.courseid"
+        :userid="urlParams.userid"
+        key="editor3"
+      />
+      <Student
+        v-else-if="viewMode === 'student'"
+        :courseid="urlParams.courseid"
+        :type="urlParams.type"
+        :userid="urlParams.userid"
+        :oid="urlParams.oid"
+        :org="urlParams.org"
+        :cid="urlParams.cid"
+        key="student"
+      />
+      <Mobile v-else key="mobile" />
+    </KeepAlive>
   </template>
-  <FullscreenSpin tip="数据初始化中,请稍等 ..." v-else  loading :mask="false" />
+  <FullscreenSpin :tip="lang.ssInitDataWait" v-else loading :mask="false" />
 </template>
 
 
@@ -13,13 +39,21 @@
 <script lang="ts" setup>
 import { onMounted, ref, provide } from 'vue'
 import { storeToRefs } from 'pinia'
-import { useScreenStore, useMainStore, useSnapshotStore, useSlidesStore } from '@/store'
+import {
+  useScreenStore,
+  useMainStore,
+  useSnapshotStore,
+  useSlidesStore,
+} from '@/store'
+import { lang } from '@/main'
 import { LOCALSTORAGE_KEY_DISCARDED_DB } from '@/configs/storage'
 import { deleteDiscardedDB } from '@/utils/database'
 import { isPC } from '@/utils/common'
 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'
@@ -40,17 +74,24 @@ const getInitialViewMode = () => {
   // 检查URL参数
   const urlParams = new URLSearchParams(window.location.search)
   const modeFromUrl = urlParams.get('mode')
-  
+  console.log(modeFromUrl)
   if (modeFromUrl === 'student') {
     return 'student'
   }
-  
+
+  if (modeFromUrl === 'editor2') {
+    return 'editor2'
+  }
+
+  if (modeFromUrl === 'editor3') {
+    return 'editor3'
+  }
   // 检查localStorage
   const modeFromStorage = localStorage.getItem('viewMode')
   if (modeFromStorage) {
     return modeFromStorage
   }
-  
+
   // 默认返回编辑模式
   return 'editor'
 }
@@ -64,7 +105,7 @@ const getUrlParams = () => {
     oid: urlParams.get('oid'),
     org: urlParams.get('org'),
     cid: urlParams.get('cid'),
-    type: urlParams.get('type')
+    type: urlParams.get('type'),
   }
 }
 
@@ -76,7 +117,7 @@ const viewMode = ref(getInitialViewMode())
 const switchViewMode = (mode: string) => {
   viewMode.value = mode
   localStorage.setItem('viewMode', mode)
-  
+
   // 更新URL参数
   const url = new URL(window.location.href)
   if (mode === 'student') {
@@ -85,7 +126,7 @@ const switchViewMode = (mode: string) => {
   else {
     url.searchParams.delete('mode')
   }
-  
+
   // 使用 history.pushState 更新URL,不刷新页面
   window.history.pushState({}, '', url.toString())
 }
@@ -99,12 +140,12 @@ if (import.meta.env.MODE !== 'development') {
 
 onMounted(async () => {
   const slides = await api.getFileData('slides')
-  console.log(slides) 
+  console.log(slides)
   slidesStore.setSlides(slides)
   // 初始化快照数据库
   // await deleteDiscardedDB()
   // snapshotStore.initSnapshotDatabase()
-  
+
   // 监听视图模式切换事件
   window.addEventListener('viewModeChanged', (event: any) => {
     if (event.detail) {
@@ -133,7 +174,7 @@ window.addEventListener('beforeunload', () => {
 .image-preview {
   position: fixed;
   inset: 0;
-  background: rgba(0,0,0,.85);
+  background: rgba(0, 0, 0, 0.85);
   display: flex;
   flex-direction: column;
   z-index: 6000;
@@ -152,20 +193,20 @@ window.addEventListener('beforeunload', () => {
   color: #fff;
   border-radius: 10px;
   cursor: pointer;
-  transition: transform .15s ease, box-shadow .2s ease, background .2s ease;
-  box-shadow: 0 2px 8px rgba(47,128,237,.3);
+  transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease;
+  box-shadow: 0 2px 8px rgba(47, 128, 237, 0.3);
 }
 .image-preview__toolbar button:hover {
   transform: translateY(-1px);
-  box-shadow: 0 6px 16px rgba(47,128,237,.35);
+  box-shadow: 0 6px 16px rgba(47, 128, 237, 0.35);
 }
 .image-preview__toolbar button:active {
   transform: translateY(0);
-  box-shadow: 0 2px 8px rgba(47,128,237,.28);
+  box-shadow: 0 2px 8px rgba(47, 128, 237, 0.28);
   background: linear-gradient(180deg, #2f80ed 0%, #1b6dde 100%);
 }
 .image-preview__toolbar button:focus-visible {
-  outline: 2px solid rgba(47,128,237,.6);
+  outline: 2px solid rgba(47, 128, 237, 0.6);
   outline-offset: 2px;
 }
 .image-preview__stage {
@@ -175,13 +216,41 @@ window.addEventListener('beforeunload', () => {
   justify-content: center;
   cursor: grab;
 }
-.image-preview__stage:active { cursor: grabbing; }
+.image-preview__stage:active {
+  cursor: grabbing;
+}
 .image-preview__img {
   max-width: 92vw;
   max-height: 92vh;
   border-radius: 8px;
-  box-shadow: 0 10px 30px rgba(0,0,0,.3);
+  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
   user-select: none;
   will-change: transform;
 }
+
+// 清空聊天记录确认弹窗样式
+.clear-confirm {
+  padding-top: 4px;
+
+  &__title {
+    font-size: 16px;
+    font-weight: 600;
+    margin-bottom: 12px;
+    color: #333;
+  }
+
+  &__content {
+    font-size: 14px;
+    color: #666;
+    line-height: 1.6;
+    margin-bottom: 20px;
+  }
+
+  &__footer {
+    display: flex;
+    justify-content: flex-end;
+    gap: 8px;
+  }
+}
+
 </style>

+ 3 - 0
src/assets/img/Subtract.svg

@@ -0,0 +1,3 @@
+<svg width="131" height="81" viewBox="0 0 131 81" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M23.9932 80.8438H7.53906C3.37547 80.8436 5.08188e-05 77.4686 0 73.3047V8.38281C0.000136568 4.21927 3.37552 0.843885 7.53906 0.84375H23.9932V80.8438ZM101.899 80.8438H28.6631V0.84375H101.899V80.8438ZM123.022 0.84375C127.186 0.84375 130.562 4.21919 130.562 8.38281V73.3047C130.562 77.4687 127.186 80.8438 123.022 80.8438H106.569V0.84375H123.022Z" fill="black" fill-opacity="0.2"/>
+</svg>

BIN
src/assets/img/ai_agent_header2.png


+ 4 - 0
src/assets/img/arrow.svg

@@ -0,0 +1,4 @@
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10 15L15 10L10 5" stroke="#333333" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 15L10 10L5 5" stroke="#333333" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

BIN
src/assets/img/arrow_left.png


BIN
src/assets/img/arrow_up.png


BIN
src/assets/img/close.png


+ 5 - 0
src/assets/img/left-a.svg

@@ -0,0 +1,5 @@
+<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect y="0.15625" width="24" height="24" rx="12" fill="white" fill-opacity="0.2"/>
+<path d="M6 12.1523H18" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12 18.1562L6 12.1562L12 6.15625" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

BIN
src/assets/img/loading.gif


+ 5 - 0
src/assets/img/right-a.svg

@@ -0,0 +1,5 @@
+<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="24" height="24" rx="12" transform="matrix(-1 0 0 1 24 0.15625)" fill="white" fill-opacity="0.2"/>
+<path d="M18 12.1523H6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12 18.1562L18 12.1562L12 6.15625" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

BIN
src/assets/img/tool_answer.png


BIN
src/assets/img/tool_choice.jpeg


+ 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 {

+ 864 - 0
src/components/CollapsibleToolbar/componets/aiChat.vue

@@ -0,0 +1,864 @@
+<template>
+    <div class="ai-chat-container">
+        <!-- 聊天区域 -->
+        <div class="chat-section" v-if="messages.length > 0" ref="chatSection">
+            <!-- 消息列表 -->
+            <div v-for="(message, index) in messages" :key="index" class="chat-message">
+                <div class="message-content user-message chat" v-if="message.content">
+                    <div v-html="message.content"></div>
+                    <!-- 显示上传的文件 -->
+                    <div class="message-files" v-if="message.sourceFiles && message.sourceFiles.length > 0">
+                        <div v-for="(file, index) in message.sourceFiles" :key="index" class="message-file-item">
+                            <span>{{ file.title }}</span>
+                        </div>
+                    </div>
+                </div>
+                <div class="message-content ai-message chat" v-if="message.aiContent || message.loading">
+                    <div v-if="message.aiContent" v-html="message.aiContent"></div>
+                    <svg v-else xmlns="http://www.w3.org/2000/svg" width="32" height="32"
+                        viewBox="0 0 24 24"><!-- Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE -->
+                        <circle cx="4" cy="12" r="3" fill="currentColor">
+                            <animate id="svgSpinners3DotsBounce0" attributeName="cy"
+                                begin="0;svgSpinners3DotsBounce1.end+0.25s" calcMode="spline" dur="0.6s"
+                                keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12" />
+                        </circle>
+                        <circle cx="12" cy="12" r="3" fill="currentColor">
+                            <animate attributeName="cy" begin="svgSpinners3DotsBounce0.begin+0.1s" calcMode="spline"
+                                dur="0.6s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12" />
+                        </circle>
+                        <circle cx="20" cy="12" r="3" fill="currentColor">
+                            <animate id="svgSpinners3DotsBounce1" attributeName="cy"
+                                begin="svgSpinners3DotsBounce0.begin+0.2s" calcMode="spline" dur="0.6s"
+                                keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12" />
+                        </circle>
+                    </svg>
+                    <button class="confirm-btn" v-if="message.jsonData?.gType !== 'chat' && !message.chatloading && message.aiContent"
+                        @click="generate(message)">{{ message.gLoading ? lang.ssLoading : lang.ssConfirm}}</button>
+                </div>
+            </div>
+        </div>
+        <!-- 输入区域 -->
+        <div class="input-section">
+            <div class="input-wrapper">
+                <div class="file-box" v-show="files.length">
+                    <div v-for="(file, index) in files" :key="index" class="file-item">
+                        <span class="file-name">{{ file.title }}</span>
+                        <button class="remove-file-btn" @click="removeFile(index)">
+                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                                <line x1="18" y1="6" x2="6" y2="18"></line>
+                                <line x1="6" y1="6" x2="18" y2="18"></line>
+                            </svg>
+                        </button>
+                    </div>
+                </div>
+                <textarea class="ai-input"
+                    :placeholder="messages.length === 0 ? lang.ssAiChatExample : lang.ssAiChatShortcut"
+                    v-model="inputText" @keyup.enter.exact="sendMessage" rows="5" />
+                <div class="input-actions">
+                    <FileInput accept="*" @change="handleFileUpload" >
+                        <button class="attach-btn">
+                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                                <path
+                                    d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48">
+                                </path>
+                            </svg>
+                        </button>
+                    </FileInput>
+                    <button class="send-btn" @click="sendMessage" v-if="!chatLoading">
+                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+                            stroke-linecap="round" stroke-linejoin="round">
+                            <line x1="22" y1="2" x2="11" y2="13"></line>
+                            <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
+                        </svg>
+                    </button>
+                    <button class="send-btn stop" @click="stopMessage" v-if="chatLoading">
+                        <svg width="32" height="32" viewBox="0 0 32 32"
+                            fill="none" xmlns="http://www.w3.org/2000/svg">
+                            <rect width="32" height="32" rx="16" fill="black" fill-opacity="0.4"></rect>
+                            <path
+                                d="M11.3333 12.333C11.3333 11.7807 11.781 11.333 12.3333 11.333H19.6666C20.2189 11.333 20.6666 11.7807 20.6666 12.333V19.6663C20.6666 20.2186 20.2189 20.6663 19.6666 20.6663H12.3333C11.781 20.6663 11.3333 20.2186 11.3333 19.6663V12.333Z"
+                                fill="white" fill-opacity="0.9"></path>
+                        </svg>
+                    </button>
+                </div>
+            </div>
+                    <!-- 输入时的快捷操作弹出 -->
+        <div class="quick-actions-popup" v-if="showQuickActions">
+            <button v-for="(action, index) in quickActions" :key="index" class="quick-action-btn" @click="sendQuickAction(action)">{{ action }}</button>
+        </div>
+        </div>
+        <!-- 初始状态 -->
+        <div class="initial-state" v-if="messages.length === 0">
+            <!-- 快捷操作 -->
+            <div class="quick-actions">
+                <button v-for="(action, index) in quickActions" :key="index" class="quick-action-btn" @click="sendQuickAction(action)">{{ action }}</button>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted, useTemplateRef, nextTick, watch } from 'vue'
+import { chat_no_stream, chat_stream, getAgentModel, chat_no_stream2 } from '@/tools/aiChat'
+import { useSlidesStore } from '@/store'
+import { lang } from '@/main'
+import MarkdownIt from 'markdown-it'
+import { getWorkPageId } from '@/services/course'
+import FileInput from '@/components/FileInput2.vue'
+import axios from '@/services/config'
+import message from '@/utils/message'
+
+interface ChatMessage {
+    uid?: string
+    role: 'ai' | 'user'
+    content?: string
+    aiContent?: string
+    oldContent?: string
+    loading?: boolean
+    chatloading?: boolean
+    gLoading?: boolean
+    rawContent?: string
+    timestamp?: Date
+    like?: boolean
+    unlike?: boolean
+    isTyping?: boolean
+    AI?: string
+    isShowSynchronization?: boolean
+    filename?: string
+    is_mind_map?: boolean
+    sourceFiles?: Array<{
+        title: string
+        id?: string
+        url?: string
+    }>
+    jsonData?: {
+        gType?: string
+        headUrl?: string
+        assistantName?: string
+        files?: Array<{
+            title: string
+            id?: string
+            url?: string
+        }>
+        sourceArray?: Array<{
+            text?: string
+            id?: string
+            title?: string
+        }>
+    }
+}
+
+const props = withDefaults(defineProps<{
+    userid?: string | null
+}>(), {
+  userid: null,
+})
+
+
+
+const inputText = ref('')
+const messages = ref<ChatMessage[]>([])
+const chatSection = useTemplateRef<HTMLElement>('chatSection')
+const chatLoading = ref(false)
+const showQuickActions = ref(false)
+const streamController = ref<{ abort: () => void } | null>(null)
+const noStreamController = ref<{ promise: Promise<string>; abort: () => void } | null>(null)
+const files = ref<Array<{ title: string; id?: string | null; url?: string; isProcessing?: boolean; cancel?: () => void }>>([])
+
+// 快捷操作短语数组
+const quickActions = [
+  lang.ssAiChatQuickAction1,
+  lang.ssAiChatQuickAction2,
+]
+
+// 监听输入变化,当输入"/"时显示快捷操作
+watch(inputText, (newValue) => {
+  if (messages.value.length > 0 && newValue === '/') {
+    showQuickActions.value = true
+  }
+  else if (newValue !== '/') {
+    showQuickActions.value = false
+  }
+})
+
+const sendMessage = () => {
+  if (chatLoading.value) {
+    return
+  }
+  // 检查是否有文件正在处理中
+  const hasProcessingFile = files.value.some(file => file.isProcessing)
+  if (hasProcessingFile) {
+    message.error(lang.ssAiChatWaitUpload)
+    return
+  }
+  if (inputText.value.trim() || files.value.length > 0) {
+    // 添加用户消息
+    messages.value.push({
+      role: 'user',
+      content: inputText.value,
+    })
+    // 模拟AI回复
+    // setTimeout(() => {
+
+    //   setTimeout(() => {
+    //     messages.value.at(-1).aiContent = '课程生成完成!为您创建了5个内容页面和3个互动工具。您可以查看底部课程大纲,或在中央区域开始编辑。',
+    //     messages.value.at(-1).jsonData = {
+    //       isChoice: true
+    //     }
+    //   }, 1000)
+    // }, 500)
+    prevChatResult()
+    messages.value.at(-1).loading = true
+    messages.value.at(-1).chatloading = true
+    messages.value.at(-1).sourceFiles = files.value.filter(file => file.id !== null).map(file => ({
+      title: file.title,
+      id: file.id
+    }))
+    chatLoading.value = true
+    sendAction(inputText.value)
+    inputText.value = ''
+    files.value = []
+  }
+}
+
+const stopMessage = () => {
+  if (streamController.value) {
+    streamController.value.abort()
+    streamController.value = null
+  }
+  if (noStreamController.value) {
+    noStreamController.value.abort()
+    noStreamController.value = null
+  }
+  chatLoading.value = false
+  if (messages.value.length > 0) {
+    messages.value.at(-1).chatloading = false
+    messages.value.at(-1).loading = false
+  }
+}
+
+// 处理文件上传
+const handleFileUpload = async (files2: File[]) => {
+  const maxSize = 10 * 1024 * 1024 // 10MB
+  const uploadPromises = []
+  
+  for (let i = 0; i < files2.length; i++) {
+    const file = files2[i]
+    if (file.size > maxSize) {
+      message.error(lang.ssAiChatFileSizeLimit)
+      continue
+    }
+    // 先添加文件到列表,显示解析中状态
+    const fileIndex = files.value.length
+    files.value.push({
+      title: file.name + ' (' + lang.ssAiChatParsing + ')',
+      id: null,
+      isProcessing: true,
+      cancel: null
+    })
+    // 创建取消控制器
+    const controller = new AbortController()
+    files.value[fileIndex].cancel = () => {
+      controller.abort()
+      files.value.splice(fileIndex, 1)
+    }
+    // 创建上传Promise并添加到数组
+    const uploadPromise = uploadFile2(file, controller.signal).then(res => {
+      if (!res) {
+        files.value.splice(fileIndex, 1)
+        return
+      }
+      // 上传成功,更新文件状态
+      files.value[fileIndex] = {
+        title: file.name,
+        id: res.results.document_id,
+        isProcessing: false
+      }
+    }).catch(error => {
+      if (error.name !== 'AbortError') {
+        console.error(lang.ssAiChatUploadFailed, error)
+        files.value.splice(fileIndex, 1)
+      }
+    })
+    
+    uploadPromises.push(uploadPromise)
+  }
+  
+  // 等待所有文件上传完成
+  await Promise.allSettled(uploadPromises)
+}
+
+// 移除文件
+const removeFile = (index: number) => {
+  const file = files.value[index]
+  if (file && file.isProcessing && file.cancel) {
+    file.cancel()
+  }
+  files.value.splice(index, 1)
+}
+
+const prevChatResult = () => {
+  nextTick(() => {
+    if (chatSection.value) {
+      chatSection.value.scrollTop = chatSection.value.scrollHeight
+    }
+  })
+}
+
+const sendQuickAction = (action: string) => {
+  inputText.value = action
+  sendMessage()
+}
+
+import { v4 as uuidv4 } from 'uuid'
+const session_name = ref('')
+const slidesStore = useSlidesStore()
+const gType = ref('chat')
+
+const sendAction = async (action: string) => {
+  const md = new MarkdownIt()
+  if (gType.value === 'chat') {
+    const result = chat_no_stream(action, agentid1.value, props.userid || '', lang.lang)
+    noStreamController.value = result
+    console.log(result)
+    const content = await result.promise
+    if (!content) {
+      stopMessage()
+      return
+    }
+    console.log(content)
+    // 渲染 Markdown 格式
+    try {
+      gType.value = JSON.parse(content).intent
+    }
+    catch (error) {
+      gType.value = 'chat'
+    }
+
+    // generate_qa // generate_choice_question
+    if (gType.value !== 'chat') {
+      messages.value.at(-1).jsonData = {
+        gType: gType.value
+      }
+    }
+  }
+  else {
+    messages.value.at(-1).jsonData = {
+      gType: gType.value
+    }
+  }
+
+
+
+  const prompt = `
+  #当前页面内容 ${JSON.stringify(slidesStore.currentSlide || '')}
+  #query: ${action}
+  `
+  chat_stream(prompt, agentid2.value, props.userid || '', lang.lang, (event) => {
+    if (event.type === 'message') {
+      messages.value.at(-1).aiContent = md.render(event.data)
+
+      messages.value.at(-1).loading = false
+      prevChatResult()
+    }
+    else if (event.type === 'messageEnd') {
+      messages.value.at(-1).aiContent = md.render(event.data)
+      messages.value.at(-1).chatloading = false
+      chatLoading.value = false
+      prevChatResult()
+    }
+  }, session_name.value, messages.value.at(-1).sourceFiles?.map(file => file.id).filter(Boolean)).then(controller => {
+    streamController.value = controller
+  }).catch(err => {
+    chatLoading.value = false
+    console.log('err', err)
+    stopMessage()
+  })
+}
+import useCreateElement from '@/hooks/useCreateElement'
+import useSlideHandler from '@/hooks/useSlideHandler'
+const { createSlide } = useSlideHandler()
+const { createFrameElement } = useCreateElement()
+
+const generate = (message: ChatMessage) => {
+  if (message.gLoading) {
+    return
+  }
+  message.gLoading = true
+  if (message.jsonData?.gType === 'generate_choice_question') {
+    console.log(message.jsonData?.gType)
+    const prompt = [
+      {
+        role: 'user',
+        content: `这是用户输入的内容:“${message.aiContent}”,根据用户输入的内容,生成选择题的json。输出一个json格式的回复,格式如下:{"testCount":1,"testTitle":"","testJson":[{"id":"7de1fdb4-bec3-4324-8986-4623f838e3d7","type":"2","teststitle":"1+1?","checkList":["1","2","3"],"timuList":[],"answer":[1],"userAnswer":[],"explanation":"解析"}]}。输出语言为${lang.lang === 'en' ? '英文' : lang.lang === 'hk' ? '繁体中文' : '简体中文'}`,
+      },
+    ]
+    chat_no_stream2(prompt, { type: 'json_object' }).then(async (res: any) => {
+      console.log('选择题', JSON.parse(res))
+      gType.value = 'chat'
+      setPageId(45, res).then(res => {
+        const url = `https://beta.pbl.cocorobo.cn/pbl-teacher-table/dist/workPage.html#/setWorkPage?id=${res}&type=${45}`
+        createSlide()
+        createFrameElement(url, 45)
+        message.gLoading = false
+      })
+    })
+  }
+  else if (message.jsonData?.gType === 'generate_qa') {
+    console.log(message.jsonData?.gType)
+    const prompt = [
+      {
+        role: 'user',
+        content: `这是用户输入的内容:“${message.aiContent}”,根据用户输入的内容,生成问答题的json。输出一个json格式的回复,格式如下:{"answerQ":"问题","answer":"","fileList":[],"imageList":[],"evaluationCriteria":"评价标准"}。输出语言为${lang.lang === 'en' ? '英文' : lang.lang === 'hk' ? '繁体中文' : '简体中文'}`,
+      },
+    ]
+    chat_no_stream2(prompt, { type: 'json_object' }).then((res: any) => {
+      console.log('问答题', JSON.parse(res))
+      gType.value = 'chat'
+      setPageId(15, res).then(res => {
+        const url = `https://beta.pbl.cocorobo.cn/pbl-teacher-table/dist/workPage.html#/setWorkPage?id=${res}&type=${15}`
+        createSlide()
+        createFrameElement(url, 15)
+        message.gLoading = false
+      })
+    })
+  }
+}
+
+const setPageId = async (tool: any, json: any) => {
+  const res = await getWorkPageId({
+    userid: props.userid || '',
+    type: tool,
+    json: json
+  })
+  return res[0][0].id
+}
+
+// 上传文件
+const uploadFile2 = async (file: File, signal?: AbortSignal): Promise<any> => {
+  try {
+    const uuid = uuidv4()
+    const formData = new FormData()
+    const timestamp = Date.now()
+    const finalExtension = file.name.split('.').pop()?.toLowerCase() || ''
+    const baseName = file.name.slice(0, -(finalExtension.length + 1))
+    
+    formData.append(
+      'file',
+      new File([file], `${baseName}${timestamp}.${finalExtension}`)
+    )
+    formData.append('collection_ids', JSON.stringify([]))
+    formData.append('id', uuid)
+    formData.append('metadata', JSON.stringify({ title: file.name }))
+    formData.append('ingestion_mode', 'fast')
+    formData.append('run_with_orchestration', 'true')
+
+    // 同步知识库
+    const res = await axios.post(
+      'https://r2rserver.cocorobo.cn/v3/documents',
+      formData,
+      {
+        headers: {
+          'Content-Type': 'multipart/form-data',
+        },
+        signal: signal
+      }
+    )
+    
+    console.log(res)
+    return res
+  }
+  catch (error) {
+    console.log('err', error)
+    if (error.name === 'AbortError') {
+      throw error
+    }
+    return ''
+  }
+}
+
+
+
+const agentid1 = ref('cbb29b41-2a4a-4453-bf8d-357929ced4bd')// 判断意图
+const agentid2 = ref('f86aa63c-b7b7-4d03-9b37-b59f116d36f3')// 生成内容
+
+
+onMounted(() => {
+  session_name.value = uuidv4()
+  getAgentModel(agentid1.value)
+  getAgentModel(agentid2.value)
+})
+</script>
+
+<style lang="scss" scoped>
+.ai-chat-container {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    padding: 16px;
+    gap: 16px;
+}
+
+.input-section {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    position: relative;
+}
+
+.input-wrapper {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    background: #fafbfc;
+    border: 1.5px solid #e5e7eb;
+    border-radius: 8px;
+    padding: 8px 12px;
+    min-height: 120px;
+}
+
+.ai-input {
+    flex: 1;
+    border: none;
+    background: transparent;
+    font-size: 14px;
+    color: #374151;
+    outline: none;
+    resize: none;
+    min-height: 80px;
+
+    &::placeholder {
+        color: #9CA3AF;
+    }
+}
+
+.input-actions {
+    display: flex;
+    // justify-content: space-between;
+    align-items: center;
+    margin-top: 8px;
+}
+
+.attach-btn {
+    width: 32px;
+    height: 32px;
+    background: none;
+    border: none;
+    cursor: pointer;
+    padding: 4px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    transition: all 0.3s ease;
+    color: #6b7280;
+
+    svg {
+        width: 20px;
+        height: 20px;
+    }
+
+    &:hover {
+        background: #FFF4E5;
+        color: #F78B22;
+        border-radius: 4px;
+    }
+
+    input[type="file"] {
+        display: none;
+    }
+}
+
+.file-box {
+    margin-bottom: 8px;
+    min-height: 24px;
+    max-height: 70px;
+    overflow-y: auto;
+
+    .file-item {
+        display: flex;
+        align-items: center;
+        background: #f5f5f5;
+        padding: 4px 8px;
+        border-radius: 4px;
+        margin-bottom: 4px;
+
+        .file-name {
+            flex: 1;
+            font-size: 12px;
+            color: #374151;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+        }
+
+        .remove-file-btn {
+            background: none;
+            border: none;
+            cursor: pointer;
+            color: #9CA3AF;
+            font-size: 12px;
+            padding: 2px;
+            margin-left: 8px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+
+            svg {
+                width: 14px;
+                height: 14px;
+            }
+
+            &:hover {
+                color: #EF4444;
+            }
+        }
+    }
+}
+
+.send-btn {
+    margin-left: auto;
+    width: 32px;
+    height: 32px;
+    border: none;
+    background: #FF9300;
+    color: #fff;
+    border-radius: 50%;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    transition: all 0.3s ease;
+
+    svg {
+        width: 20px;
+        height: 20px;
+    }
+
+    &:hover {
+        background: #E68A00;
+    }
+
+    &.stop {
+        background: unset;
+        width: auto;
+        svg {
+            width: 32px;
+            height: 32px;
+        }
+    }
+}
+
+.quick-actions {
+    // display: flex;
+    // flex-direction: column;
+    // gap: 8px;
+
+    .quick-action-btn {
+        padding: 5px 10px;
+        border: 1px solid #f7c58f;
+        background: #FFF9F2;
+        color: #6b4a1f;
+        border-radius: 16px;
+        font-size: 12px;
+        cursor: pointer;
+        text-align: left;
+        display: block;
+
+        &:hover {
+            border-color: #F78B22;
+            background: #FFF4E5;
+            color: #111827;
+        }
+
+        +.quick-action-btn {
+            margin-top: 8px;
+        }
+    }
+}
+
+
+
+.chat-section {
+    // flex: 1;
+    height: calc(100% - 155px);
+    overflow-y: auto;
+    display: flex;
+    flex-direction: column;
+    // gap: 16px;
+    padding-right: 8px;
+
+    &::-webkit-scrollbar {
+        width: 6px;
+    }
+
+    &::-webkit-scrollbar-track {
+        background: #F3F4F6;
+        border-radius: 3px;
+    }
+
+    &::-webkit-scrollbar-thumb {
+        background: #D1D5DB;
+        border-radius: 3px;
+
+        &:hover {
+            background: #9CA3AF;
+        }
+    }
+}
+
+.chat-message {
+    max-width: 100%;
+    margin-bottom: 10px;
+    display: flex;
+    flex-direction: column;
+
+    .message-content {
+        display: flex;
+        flex-direction: column;
+        border-radius: 8px;
+        padding: 8px 10px;
+        font-size: 14px;
+        line-height: 1.5;
+        color: #374151;
+        width: fit-content;
+
+        +.message-content {
+            margin-top: 10px;
+        }
+    }
+}
+
+.message-content {
+    &.ai-message {
+        align-self: flex-start;
+        background: #fafbfc;
+        border: 1.5px solid #e5e7eb;
+        border-bottom-left-radius: 2px;
+
+        &>svg {
+            width: 17px;
+            height: 17px;
+        }
+    }
+}
+
+.message-content {
+    &.user-message {
+        align-self: flex-end;
+        background: #FFF4E5;
+        border: 1.5px solid #F78B22;
+        border-bottom-right-radius: 2px;
+    }
+}
+
+.initial-state {
+    display: flex;
+    flex-direction: column;
+    justify-content: flex-start;
+    align-items: flex-start;
+    // padding: 24px;
+    gap: 16px;
+}
+
+
+.confirm-btn {
+    margin-top: 10px;
+    padding: 6px 15px;
+    background: #FF9300;
+    color: white;
+    border: none;
+    border-radius: 8px;
+    font-size: 14px;
+    cursor: pointer;
+    margin-left: auto;
+    transition: all 0.3s ease;
+
+    &:hover {
+        background: #E68A00;
+    }
+}
+
+ul {
+    margin: 8px 0;
+    padding-left: 20px;
+
+    li {
+        margin: 4px 0;
+    }
+}
+
+.quick-actions-popup {
+    position: absolute;
+    bottom: 100%;
+    left: 0;
+    right: 0;
+    background: white;
+    border: 1px solid #E5E7EB;
+    border-radius: 8px;
+    box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+    padding: 8px;
+    z-index: 100;
+    margin-bottom: 8px;
+
+    .quick-action-btn {
+        width: 100%;
+        text-align: left;
+        padding: 10px 12px;
+        background: white;
+        border: none;
+        border-radius: 6px;
+        font-size: 14px;
+        color: #374151;
+        cursor: pointer;
+        transition: all 0.2s ease;
+
+        &:hover {
+            background: #F3F4F6;
+        }
+    }
+}
+</style>
+
+<style>
+.chat table {
+    text-align: center;
+    border-spacing: 0;
+    border-left: 1px solid #000;
+    border-bottom: 1px solid #000;
+}
+
+.chat table td,
+.chat table th {
+    border-top: 1px solid #000;
+    border-right: 1px solid #000;
+    padding: 10px;
+}
+
+.message-files {
+    margin-top: 8px;
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+}
+
+.message-file-item {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    font-size: 12px;
+    color: #6b7280;
+    background: #f3f4f6;
+    padding: 4px 8px;
+    border-radius: 4px;
+    max-width: 200px;
+    overflow: hidden;
+}
+
+.message-file-item span {
+    flex: 1;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+</style>

+ 640 - 0
src/components/CollapsibleToolbar/index.vue

@@ -0,0 +1,640 @@
+<template>
+  <div class="collapsible-toolbar" :class="{ collapsed: isCollapsed }">
+    <div class="toolbar-content" v-show="!isCollapsed">
+      <div class="sidebar-content">
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'interactive' }" @click="toggleSubmenu('interactive')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="3"/>
+            <path d="M12 1v6m0 6v6M5.64 5.64l4.24 4.24m4. 4.24l4.24 4.24M1 12h6m6 0h6M5.64 18.36l4.24-4.24m4.24-4.24l4kt-4.24"/>
+          </svg>
+          <span class="item-label">{{ lang.ssInteract }}</span>
+        </div>
+        <div class="sidebar-item" @click="handleToolClick('h5page')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10"/>
+            <path d="M2 12h20"/>
+            <path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>
+          </svg>
+          <span class="item-label">{{ lang.ssHPage }}</span>
+        </div>
+        <div class="sidebar-item" @click="handleToolClick('aiapp')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <rect x="3" y="3" width="7" height="7"/>
+            <rect x="14" y="3" width="7" height="7"/>
+            <rect x="14" y="14" width="7" height="7"/>
+            <rect x="3" y="14" width="7" height="7"/>
+          </svg>
+          <span class="item-label">{{ lang.ssAiApp }}</span>
+        </div>
+        <div class="sidebar-item" @click="handleToolClick('video')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <rect x="3" y="4" width="18" height="16" rx="2" ry="2"/>
+            <polygon points="10 9 16 12 10 15 10 9"/>
+          </svg>
+          <span class="item-label">{{ lang.ssVideo }}</span>
+        </div>
+        <div class="sidebar-item" @click="handleToolClick('creative')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10"/>
+            <line x1="12" y1="8" x2="12" y2="16"/>
+            <line x1="8" y1="12" x2="16" y2="12"/>
+          </svg>
+          <span class="item-label">{{ lang.ssCreative }}</span>
+        </div>
+        <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"/>
+          </svg>
+          <span class="item-label">{{ lang.ssContentList }}</span>
+        </div>
+      </div>
+    </div>
+    
+    <div class="submenu" :class="{ visible: activeSubmenu === 'interactive' }">
+      <div class="submenu-item" @click="handleToolClick('choice')">
+        <svg class="submenu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <circle cx="12" cy="12" r="10"/>
+          <path d="M12 16v-4m0-4h.01"/>
+        </svg>
+        <span class="submenu-label">{{ lang.ssChoiceQ }}</span>
+      </div>
+      <div class="submenu-item" @click="handleToolClick('qa')">
+        <svg class="submenu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
+        </svg>
+        <span class="submenu-label">{{ lang.ssQandA }}</span>
+      </div>
+    </div>
+    
+    <div class="content-list-submenu" :class="{ visible: activeSubmenu === 'contentlist' }">
+      <div v-if="contentList.length === 0" class="empty-state">
+        <div class="empty-icon">📚</div>
+        <div class="empty-title">{{ lang.ssNoLearn }}</div>
+        <div class="empty-title">{{ lang.ssNeedUpload }}</div>
+      </div>
+      <div v-else class="content-list">
+        <div 
+          v-for="(item, index) in contentList" 
+          :key="index"
+          class="content-item"
+        >
+          <div class="content-main" @click="insertContent(item)">
+            <svg class="content-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"/>
+            </svg>
+            <span class="content-label">{{ item.title }}</span>
+            <span class="type-tag" :class="getTypeClass(item.tool)">{{ getTypeLabel(item.tool) }}</span>
+          </div>
+          <div class="content-actions">
+            <template v-if="item.tool === 74 || item.tool === 75">
+              <div class="action-btn" @click.stop="previewVideo(item)" :title="lang.ssPreview">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <polygon points="5 3 19 12 5 21 5 3"/>
+                </svg>
+              </div>
+            </template>
+            <template v-if="item.tool !== 74 && item.tool !== 75 && item.tool !== 76">
+              <div class="action-btn" @click.stop="editContent(item)" :title="lang.ssEdit">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
+                  <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
+                </svg>
+              </div>
+            </template>
+            <template v-if="item.tool !== 76">
+              <div class="action-btn" @click.stop="copyContent(item)" :title="lang.ssCopy">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
+                  <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
+                </svg>
+              </div>
+            </template>
+            <div class="action-btn delete" @click.stop="deleteContent(item)" :title="lang.ssDelete">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <polyline points="3 6 5 6 21 6"/>
+                <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
+              </svg>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<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'
+import { lang } from '@/main'
+
+interface ContentItem {
+  tool?: number
+  title?: string
+  url?: string
+  id?: string
+}
+
+const props = withDefaults(defineProps<{
+  defaultCollapsed?: boolean
+}>(), {
+  defaultCollapsed: false
+})
+
+const emit = defineEmits<{
+  (e: 'toggle', collapsed: boolean): void
+}>()
+
+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
+  emit('toggle', isCollapsed.value)
+}
+
+const toggleSubmenu = (menu: string) => {
+  if (activeSubmenu.value === menu) {
+    activeSubmenu.value = null
+  }
+  else {
+    activeSubmenu.value = menu
+    if (menu === 'contentlist') {
+      loadContentList()
+    }
+  }
+}
+
+const handleToolClick = (tool: string) => {
+  interface ParentWindowWithToolList extends Window {
+    addTool?: (id: number) => void;
+    openVideoUploadDialog?: () => void;
+    openApplicationCenter?: () => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  console.log('点击工具:', tool)
+  if (tool === 'h5page') {
+    parentWindow?.addTool?.(73)
+  }
+  else if (tool === 'aiapp') {
+    parentWindow?.addTool?.(72)
+  }
+  else if (tool === 'video') {
+    parentWindow?.openVideoUploadDialog?.()
+  }
+  else if (tool === 'creative') {
+    parentWindow?.openApplicationCenter?.()
+  }
+  else if (tool === 'choice') {
+    parentWindow?.addTool?.(45)
+  }
+  else if (tool === 'qa') {
+    parentWindow?.addTool?.(15)
+  }
+}
+
+const loadContentList = () => {
+  try {
+    interface ParentWindowWithToolList extends Window {
+      pptToolList?: ContentItem[]
+    }
+    const parentWindow = window.parent as ParentWindowWithToolList
+    contentList.value = parentWindow?.pptToolList || []
+  }
+  catch (error) {
+    console.error('加载内容列表失败:', error)
+    contentList.value = []
+  }
+}
+
+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;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.previewVideo?.(item)
+}
+
+const editContent = (item: ContentItem) => {
+  interface ParentWindowWithToolList extends Window {
+    toolBtn?: (action: number, id: string) => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.toolBtn?.(0, item.id || '')
+}
+
+const copyContent = (item: ContentItem) => {
+  interface ParentWindowWithToolList extends Window {
+    toolBtn?: (action: number, id: string) => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.toolBtn?.(1, item.id || '')
+}
+
+const deleteContent = (item: ContentItem) => {
+  interface ParentWindowWithToolList extends Window {
+    toolBtn?: (action: number, id: string) => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.toolBtn?.(2, item.id || '')
+}
+
+const getTypeLabel = (type?: number) => {
+  const typeMap: Record<number, string> = {
+    45: lang.ssChoiceQ,
+    15: lang.ssQATest,
+    72: lang.ssAiApp,
+    73: lang.ssHPage,
+    74: lang.ssVideo,
+    75: lang.ssBiliVideo,
+    76: lang.ssCreative,
+  }
+  return typeMap[type || 0] || lang.ssUnknown
+}
+
+const getTypeClass = (type?: number) => {
+  const classMap: Record<number, string> = {
+    45: 'type-choice',
+    15: 'type-question',
+    72: 'type-ai',
+    73: 'type-h5',
+    74: 'type-video',
+    75: 'type-bilibili',
+    76: 'type-app-center'
+  }
+  return classMap[type || 0] || 'type-default'
+}
+</script>
+
+<style lang="scss" scoped>
+.collapsible-toolbar {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  background: #fff;
+  border-right: 1px solid #e5e7eb;
+  transition: width 0.3s ease;
+}
+
+.toolbar-content {
+  flex: 1;
+  overflow: hidden;
+  padding: 16px 8px;
+}
+
+.sidebar-content {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  width: 84px;
+  position: relative;
+}
+
+.sidebar-item {
+  width: 84px;
+  padding: 12px 8px;
+  border-radius: 12px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  cursor: pointer;
+  transition: all 0.2s;
+  position: relative;
+
+  &:hover {
+    background: #f3f4f6;
+  }
+
+  &:active {
+    background: #e5e7eb;
+  }
+
+  &.active {
+    background: #eef3ff;
+    box-shadow: 0 2px 8px rgba(40, 92, 245, 0.15);
+  }
+
+  &.active::after {
+    content: '';
+    position: absolute;
+    left: -8px;
+    top: 50%;
+    transform: translateY(-50%);
+    width: 4px;
+    height: 32px;
+    background: #285cf5;
+    border-radius: 0 2px 2px 0;
+  }
+}
+
+.item-icon {
+  width: 22px;
+  height: 22px;
+  flex-shrink: 0;
+  color: #6b7280;
+}
+
+.sidebar-item:hover .item-icon,
+.sidebar-item.active .item-icon {
+  color: #285cf5;
+}
+
+.item-label {
+  font-size: 11px;
+  font-weight: 500;
+  color: #6b7280;
+  text-align: center;
+}
+
+.sidebar-item:hover .item-label,
+.sidebar-item.active .item-label {
+  color: #285cf5;
+  font-weight: 600;
+}
+
+.submenu {
+  width: 0;
+  min-width: 0;
+  overflow: hidden;
+  transition: all 0.3s ease;
+  background: #fff;
+  border-radius: 12px;
+  z-index: 100;
+
+  &.visible {
+    width: 160px;
+    min-width: 160px;
+    padding: 8px 0;
+  }
+}
+
+.submenu-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 10px 16px;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  font-size: 13px;
+  color: #6b7280;
+  border-radius: 8px;
+  margin: 4px 8px;
+
+  &:hover {
+    background-color: #f3f4f6;
+    color: #285cf5;
+  }
+
+  &:active {
+    background-color: #e5e7eb;
+  }
+}
+
+.submenu-icon {
+  width: 18px;
+  height: 18px;
+  flex-shrink: 0;
+  color: #9ca3af;
+}
+
+.submenu-item:hover .submenu-icon {
+  color: #285cf5;
+}
+
+.submenu-label {
+  font-size: 13px;
+  color: #6b7280;
+}
+
+.submenu-item:hover .submenu-label {
+  color: #285cf5;
+}
+
+.content-list-submenu {
+  width: 0;
+  min-width: 0;
+  overflow: hidden;
+  transition: all 0.3s ease;
+  background: #fff;
+  border-radius: 12px;
+  z-index: 100;
+
+  &.visible {
+    width: 380px;
+    min-width: 380px;
+    padding: 8px 0;
+  }
+}
+
+.empty-state {
+  text-align: center;
+  padding: 20px 10px;
+  color: #666;
+}
+
+.empty-icon {
+  font-size: 32px;
+  margin-bottom: 8px;
+}
+
+.empty-title {
+  font-size: 12px;
+  color: #999;
+}
+
+.content-list {
+  max-height: 400px;
+  overflow-y: auto;
+  padding: 4px 0;
+
+  &::-webkit-scrollbar {
+    width: 4px;
+  }
+
+  &::-webkit-scrollbar-track {
+    background: #f1f1f1;
+    border-radius: 2px;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: #c1c1c1;
+    border-radius: 2px;
+
+    &:hover {
+      background: #a8a8a8;
+    }
+  }
+}
+
+.content-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 12px;
+  border-radius: 8px;
+  margin: 2px 8px;
+  position: relative;
+  transition: all 0.2s ease;
+
+  &:hover {
+    background-color: #f8f9fa;
+  }
+}
+
+.content-main {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex: 1;
+  min-width: 0;
+  cursor: pointer;
+  padding: 4px;
+  border-radius: 6px;
+  transition: all 0.2s ease;
+
+  &:hover {
+    background-color: #f3f4f6;
+  }
+}
+
+.content-icon {
+  width: 16px;
+  height: 16px;
+  flex-shrink: 0;
+  color: #9ca3af;
+}
+
+.content-main:hover .content-icon {
+  color: #285cf5;
+}
+
+.content-label {
+  flex: 1;
+  font-size: 13px;
+  color: #6b7280;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.content-main:hover .content-label {
+  color: #285cf5;
+}
+
+.content-actions {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.action-btn {
+  width: 28px;
+  height: 28px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 4px;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  color: #9ca3af;
+
+  svg {
+    width: 14px;
+    height: 14px;
+  }
+
+  &:hover {
+    background-color: #f3f4f6;
+    color: #285cf5;
+  }
+
+  &.delete {
+    &:hover {
+      background-color: #fee2e2;
+      color: #ef4444;
+    }
+  }
+}
+
+.type-tag {
+  padding: 2px 6px;
+  border-radius: 4px;
+  font-size: 10px;
+  font-weight: 500;
+  color: #fff;
+  white-space: nowrap;
+  flex-shrink: 0;
+
+  &.type-choice {
+    background-color: #4caf50;
+  }
+
+  &.type-question {
+    background-color: #ff9800;
+  }
+
+  &.type-ai {
+    background-color: #2196f3;
+  }
+
+  &.type-h5 {
+    background-color: #9c27b0;
+  }
+
+  &.type-video {
+    background-color: #f44336;
+  }
+
+  &.type-bilibili {
+    background-color: #fb7299;
+  }
+
+  &.type-default {
+    background-color: #757575;
+  }
+
+  &.type-app-center {
+    background-color: #673ab7;
+  }
+}
+</style>

+ 1578 - 0
src/components/CollapsibleToolbar/index2.vue

@@ -0,0 +1,1578 @@
+<template>
+  <div class="collapsible-toolbar" :class="{ collapsed: isCollapsed }">
+    <div class="toolbar-content" v-show="!isCollapsed">
+      <div class="sidebar-content">
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'cocoai' }" @click="toggleSubmenu('cocoai')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M12 2L2 7l10 5 10-5-10-5z"></path>
+            <path d="M2 17l10 5 10-5"></path>
+            <path d="M2 12l10 5 10-5"></path>
+          </svg>
+          <span class="item-label">Coco AI</span>
+        </div>
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'page' }" @click="toggleSubmenu('page')">
+          <svg class="item-icon" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 1">
+              <path id="Vector"
+                d="M12.8332 1.83398H5.49984C5.01361 1.83398 4.54729 2.02714 4.20347 2.37096C3.85966 2.71477 3.6665 3.18109 3.6665 3.66732V18.334C3.6665 18.8202 3.85966 19.2865 4.20347 19.6303C4.54729 19.9742 5.01361 20.1673 5.49984 20.1673H16.4998C16.9861 20.1673 17.4524 19.9742 17.7962 19.6303C18.14 19.2865 18.3332 18.8202 18.3332 18.334V7.33398L12.8332 1.83398Z"
+                stroke="currentColor" stroke-width="1.83333" />
+              <path id="Vector_2" d="M12.8335 1.83398V7.33398H18.3335" stroke="currentColor" stroke-width="1.83333" />
+            </g>
+          </svg>
+          <span class="item-label">{{ lang.ssPage }}</span>
+        </div>
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'interactive' }"
+          @click="toggleSubmenu('interactive')">
+          <svg class="item-icon" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Frame">
+              <path id="Vector"
+                d="M4.44727 14.6738V14.5449L5.84805 13.041H6.02422L9.43164 16.2766V7.46797L9.4875 7.41211H11.9969L12.0098 7.425V13.5137H16.7234L16.8781 13.6684V18.9707H18.5969V12.7789L17.6086 11.7949H13.5996V6.62578L12.7531 5.7793H8.73125L7.8418 6.66875V12.6844L6.60859 11.4512H5.23789L2.85742 13.9863V15.2324L6.35508 18.9707H8.60234L4.44727 14.6738Z"
+                fill="currentColor" />
+              <path id="Vector_2"
+                d="M2.49219 2.79297V10.5273H6.40234V8.76562H4.25391V4.55469H17.7461V8.76562H15.0391V10.5273H19.4219V2.79297H2.49219Z"
+                fill="currentColor" />
+            </g>
+          </svg>
+          <span class="item-label">{{ lang.ssInteract }}</span>
+        </div>
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'aiapp' }" @click="toggleSubmenu('aiapp')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <rect x="3" y="3" width="7" height="7" />
+            <rect x="14" y="3" width="7" height="7" />
+            <rect x="14" y="14" width="7" height="7" />
+            <rect x="3" y="14" width="7" height="7" />
+          </svg>
+          <span class="item-label">{{ lang.ssAiApp }}</span>
+        </div>
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'h5page' }" @click="toggleSubmenu('h5page')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10" />
+            <path d="M2 12h20" />
+            <path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
+          </svg>
+          <span class="item-label">{{ lang.ssInteractiveWebpage }}</span>
+        </div>
+        <!-- <div class="sidebar-item" @click="handleToolClick('video')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <rect x="3" y="4" width="18" height="16" rx="2" ry="2" />
+            <polygon points="10 9 16 12 10 15 10 9" />
+          </svg>
+          <span class="item-label">{{ lang.ssVideo }}</span>
+        </div> -->
+        <!-- <div class="sidebar-item" @click="handleToolClick('creative')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10" />
+            <line x1="12" y1="8" x2="12" y2="16" />
+            <line x1="8" y1="12" x2="16" y2="12" />
+          </svg>
+          <span class="item-label">{{ lang.ssCreative }}</span>
+        </div> -->
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'multimedia' }"
+          @click="toggleSubmenu('multimedia')">
+          <svg class="item-icon" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 1">
+              <path id="Vector"
+                d="M20.1668 17.4167C20.1668 17.9029 19.9737 18.3692 19.6299 18.713C19.286 19.0568 18.8197 19.25 18.3335 19.25H3.66683C3.1806 19.25 2.71428 19.0568 2.37047 18.713C2.02665 18.3692 1.8335 17.9029 1.8335 17.4167V4.58333C1.8335 4.0971 2.02665 3.63079 2.37047 3.28697C2.71428 2.94315 3.1806 2.75 3.66683 2.75H8.25016L10.0835 5.5H18.3335C18.8197 5.5 19.286 5.69315 19.6299 6.03697C19.9737 6.38079 20.1668 6.8471 20.1668 7.33333V17.4167Z"
+                stroke="currentColor" stroke-width="1.83333" />
+            </g>
+          </svg>
+          <span class="item-label">{{ lang.ssMultimedia }}</span>
+        </div>
+      </div>
+    </div>
+    <div class="submenu" :class="{ visible: activeSubmenu === 'cocoai' }">
+      <div class="submenu-title" style="margin-bottom: 0;">
+        <div class="title">Coco AI</div>
+        <div class="close-icon" @click="toggleSubmenu('cocoai')">
+          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 3">
+              <g id="Component 1">
+                <path id="Vector" d="M16 18L12 14L16 10" stroke="#9CA3AF" stroke-width="1.33333" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+      <div class="submenu-content">
+        <AiChat :userid="props.userid" />
+      </div>
+    </div>
+    <div class="submenu" :class="{ visible: activeSubmenu === 'page' }">
+      <div class="submenu-title">
+        <div class="title">{{ lang.ssAddTemplatePage }}</div>
+        <div class="close-icon" @click="toggleSubmenu('page')">
+          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 3">
+              <g id="Component 1">
+                <path id="Vector" d="M16 18L12 14L16 10" stroke="#9CA3AF" stroke-width="1.33333" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+      <div class="submenu-item-box2">
+        <div class="submenu-item" @click="handleToolClick('titlepage')">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector" d="M8 14V8H40V14M18 40H30M24 8V40" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssTitlePage }}</span>
+        </div>
+        <div class="submenu-item" @click="handleToolClick('ImagePage')">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M38 6H10C7.79086 6 6 7.79086 6 10V38C6 40.2091 7.79086 42 10 42H38C40.2091 42 42 40.2091 42 38V10C42 7.79086 40.2091 6 38 6Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2"
+                  d="M17 20C18.6569 20 20 18.6569 20 17C20 15.3431 18.6569 14 17 14C15.3431 14 14 15.3431 14 17C14 18.6569 15.3431 20 17 20Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3" d="M42 30L32 20L10 42" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssImagePage }}</span>
+        </div>
+        <div class="submenu-item" @click="handleToolClick('ContentPage')">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M28 4H12C10.9391 4 9.92172 4.42143 9.17157 5.17157C8.42143 5.92172 8 6.93913 8 8V40C8 41.0609 8.42143 42.0783 9.17157 42.8284C9.92172 43.5786 10.9391 44 12 44H36C37.0609 44 38.0783 43.5786 38.8284 42.8284C39.5786 42.0783 40 41.0609 40 40V16L28 4Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3" d="M32 26H16" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_4" d="M32 34H16" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssContentPage }}</span>
+        </div>
+        <div class="submenu-item" @click="handleToolClick('ImageTextPage')">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M18 6H8C6.89543 6 6 6.89543 6 8V40C6 41.1046 6.89543 42 8 42H18C19.1046 42 20 41.1046 20 40V8C20 6.89543 19.1046 6 18 6Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2"
+                  d="M40 6H30C28.8954 6 28 6.89543 28 8V40C28 41.1046 28.8954 42 30 42H40C41.1046 42 42 41.1046 42 40V8C42 6.89543 41.1046 6 40 6Z"
+                  stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssImageTextPage }}</span>
+        </div>
+      </div>
+      <FileInput accept="application/vnd.openxmlformats-officedocument.presentationml.presentation"
+        @change="handleFileUpload">
+        <div class="submenu-upload">
+          <div class="submenu-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="M24.5 17.5V22.1667C24.5 22.7855 24.2542 23.379 23.8166 23.8166C23.379 24.2542 22.7855 24.5 22.1667 24.5H5.83333C5.21449 24.5 4.621 24.2542 4.18342 23.8166C3.74583 23.379 3.5 22.7855 3.5 22.1667V17.5"
+                  stroke="currentColor" stroke-width="2.33333" />
+                <path id="Vector_2" d="M19.8334 9.33333L14.0001 3.5L8.16675 9.33333" stroke="currentColor"
+                  stroke-width="2.33333" />
+                <path id="Vector_3" d="M14 3.5V17.5" stroke="currentColor" stroke-width="2.33333" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssUploadPPT }}</span>
+        </div>
+      </FileInput>
+    </div>
+    <div class="submenu" :class="{ visible: activeSubmenu === 'interactive' }">
+      <div class="submenu-title">
+        <div class="title">{{ lang.ssAddInteractiveTool }}</div>
+        <div class="close-icon" @click="toggleSubmenu('interactive')">
+          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 3">
+              <g id="Component 1">
+                <path id="Vector" d="M16 18L12 14L16 10" stroke="#9CA3AF" stroke-width="1.33333" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+      <transition name="fade" mode="out-in">
+        <div class="submenu-panel" v-if="!hoveredTool">
+          <svg key="svg" width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 1">
+              <path id="Vector"
+                d="M102 15H18C13.5817 15 10 18.5817 10 23V57C10 61.4183 13.5817 65 18 65H102C106.418 65 110 61.4183 110 57V23C110 18.5817 106.418 15 102 15Z"
+                fill="#FFFAF0" stroke="#FF9300" stroke-width="2" />
+              <path id="Vector_2"
+                d="M30 34C32.2091 34 34 32.2091 34 30C34 27.7909 32.2091 26 30 26C27.7909 26 26 27.7909 26 30C26 32.2091 27.7909 34 30 34Z"
+                fill="#FF9300" />
+              <path id="Vector_3"
+                d="M97 27H43C41.3431 27 40 28.3431 40 30C40 31.6569 41.3431 33 43 33H97C98.6569 33 100 31.6569 100 30C100 28.3431 98.6569 27 97 27Z"
+                fill="#FFD9A8" />
+              <path id="Vector_4"
+                d="M30 49C32.2091 49 34 47.2091 34 45C34 42.7909 32.2091 41 30 41C27.7909 41 26 42.7909 26 45C26 47.2091 27.7909 49 30 49Z"
+                fill="#D1D5DB" />
+              <path id="Vector_5"
+                d="M82 42H43C41.3431 42 40 43.3431 40 45C40 46.6569 41.3431 48 43 48H82C83.6569 48 85 46.6569 85 45C85 43.3431 83.6569 42 82 42Z"
+                fill="#E5E7EB" />
+            </g>
+          </svg>
+          <div class="detail">{{ lang.ssSelectToolCreateInteractive }}</div>
+        </div>
+        <img class="submenu-img" v-else-if="hoveredTool === 'qa'" key="qa" :src="toolAnswer" alt="">
+        <img class="submenu-img" v-else-if="hoveredTool === 'choice'" key="choice" :src="toolChoice" alt="">
+      </transition>
+      <div class="submenu-item-box">
+        <div class="submenu-item" @click="handleToolClick('choice')" @mouseenter="hoveredTool = 'choice'"
+          @mouseleave="hoveredTool = null">
+          <svg class="submenu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10" />
+            <path d="M12 16v-4m0-4h.01" />
+          </svg>
+          <span class="submenu-label">{{ lang.ssChoiceQ }}</span>
+        </div>
+        <div class="submenu-item" @click="handleToolClick('qa')" @mouseenter="hoveredTool = 'qa'"
+          @mouseleave="hoveredTool = null">
+          <svg class="submenu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
+          </svg>
+          <span class="submenu-label">{{ lang.ssQandA }}</span>
+        </div>
+      </div>
+    </div>
+    <div class="submenu" :class="{ visible: activeSubmenu === 'aiapp' }">
+      <div class="submenu-title">
+        <div class="title">{{ lang.ssAddAIApp }}</div>
+        <div class="close-icon" @click="toggleSubmenu('aiapp')">
+          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 3">
+              <g id="Component 1">
+                <path id="Vector" d="M16 18L12 14L16 10" stroke="#9CA3AF" stroke-width="1.33333" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+      <div class="submenu-item-box2">
+        <div class="submenu-item" @click="handleToolClick('aiapp')">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M18 6H8C6.89543 6 6 6.89543 6 8V18C6 19.1046 6.89543 20 8 20H18C19.1046 20 20 19.1046 20 18V8C20 6.89543 19.1046 6 18 6Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2"
+                  d="M40 6H30C28.8954 6 28 6.89543 28 8V18C28 19.1046 28.8954 20 30 20H40C41.1046 20 42 19.1046 42 18V8C42 6.89543 41.1046 6 40 6Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3"
+                  d="M40 28H30C28.8954 28 28 28.8954 28 30V40C28 41.1046 28.8954 42 30 42H40C41.1046 42 42 41.1046 42 40V30C42 28.8954 41.1046 28 40 28Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_4"
+                  d="M18 28H8C6.89543 28 6 28.8954 6 30V40C6 41.1046 6.89543 42 8 42H18C19.1046 42 20 41.1046 20 40V30C20 28.8954 19.1046 28 18 28Z"
+                  stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssAppCenter }}</span>
+        </div>
+        <div class="submenu-item" @click="handleToolClick('createApp')">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M24 44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C12.9543 4 4 12.9543 4 24C4 35.0457 12.9543 44 24 44Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2" d="M24 16V32" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3" d="M16 24H32" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssCreateApp }}</span>
+        </div>
+      </div>
+    </div>
+    <div class="submenu" :class="{ visible: activeSubmenu === 'h5page' }">
+      <div class="submenu-title">
+        <div class="title">{{ lang.ssAddInteractiveWebpage }}</div>
+        <div class="close-icon" @click="toggleSubmenu('h5page')">
+          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 3">
+              <g id="Component 1">
+                <path id="Vector" d="M16 18L12 14L16 10" stroke="#9CA3AF" stroke-width="1.33333" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+      <div class="submenu-item-box2">
+        <!-- <div class="submenu-item">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M40 6H8C5.79086 6 4 7.79086 4 10V30C4 32.2091 5.79086 34 8 34H40C42.2091 34 44 32.2091 44 30V10C44 7.79086 42.2091 6 40 6Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2" d="M4 14H44" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3"
+                  d="M10 11C10.5523 11 11 10.5523 11 10C11 9.44772 10.5523 9 10 9C9.44772 9 9 9.44772 9 10C9 10.5523 9.44772 11 10 11Z"
+                  fill="currentColor" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_4"
+                  d="M14 11C14.5523 11 15 10.5523 15 10C15 9.44772 14.5523 9 14 9C13.4477 9 13 9.44772 13 10C13 10.5523 13.4477 11 14 11Z"
+                  fill="currentColor" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_5"
+                  d="M18 11C18.5523 11 19 10.5523 19 10C19 9.44772 18.5523 9 18 9C17.4477 9 17 9.44772 17 10C17 10.5523 17.4477 11 18 11Z"
+                  fill="currentColor" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_6" d="M16 22H32M16 28H26" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssWebpageCenter }}</span>
+        </div> -->
+        <div class="submenu-item" @click="handleToolClick('uploadWebpage')">
+          <div class="submenu-icon">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <circle cx="12" cy="12" r="10"></circle>
+              <path d="M2 12h20"></path>
+              <path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"></path>
+              <path d="M16 8l-4 4-4-4" stroke-width="1.5"></path>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssUploadWebpageLink }}</span>
+        </div>
+        <!-- <div class="submenu-item">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M42 30V38C42 39.0609 41.5786 40.0783 40.8284 40.8284C40.0783 41.5786 39.0609 42 38 42H10C8.93913 42 7.92172 41.5786 7.17157 40.8284C6.42143 40.0783 6 39.0609 6 38V30"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2" d="M34 16L24 6L14 16" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3" d="M24 6V30" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssUploadWebpage }}</span>
+        </div> -->
+        <div class="submenu-item" @click="handleToolClick('createWebpage')">
+          <div class="submenu-icon">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <rect x="3" y="3" width="18" height="14" rx="2"></rect>
+              <path d="M12 8v6"></path>
+              <path d="M9 11h6"></path>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssNewWebpage }}</span>
+        </div>
+        <!-- <div class="submenu-item">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M24 44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C12.9543 4 4 12.9543 4 24C4 35.0457 12.9543 44 24 44Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2" d="M4 24H44" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3"
+                  d="M24 4C29.0026 9.47671 31.8455 16.5841 32 24C31.8455 31.4159 29.0026 38.5233 24 44C18.9974 38.5233 16.1545 31.4159 16 24C16.1545 16.5841 18.9974 9.47671 24 4Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_4" d="M32 16L24 24L16 16" stroke="currentColor" stroke-width="3" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssCrawlWebpage }}</span>
+        </div> -->
+      </div>
+    </div>
+    <div class="submenu" :class="{ visible: activeSubmenu === 'uploadWebpage' }">
+      <div class="submenu-title">
+        <div class="title">{{ lang.ssUploadWebpageLink }}</div>
+        <div class="close-icon" @click="activeSubmenu = 'h5page'">
+          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 3">
+              <g id="Component 1">
+                <path id="Vector" d="M16 18L12 14L16 10" stroke="#9CA3AF" stroke-width="1.33333" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+      <div class="line_box">
+        <div class="webpage-link-container">
+          <h3 class="webpage-link-title">{{ lang.ssWebpageLink }}</h3>
+          <input type="text" class="webpage-link-input" :placeholder="lang.ssEnterCompleteUrl" v-model="webpageUrl"
+            @input="handleUrlInput" />
+          <button class="webpage-link-button"
+            :class="{ 'loading': isLoading, 'error': isValidUrl === false, 'disabled': isValidUrl === null || isLoading }"
+            :disabled="isValidUrl === null || isLoading || isValidUrl === false" @click="uploadWebpageLink">
+            {{ isLoading ? lang.ssUploading : isValidUrl === null ? lang.ssWaitingForInput : isValidUrl === false ?
+              lang.ssInvalidUrl : lang.ssStartUpload }}
+          </button>
+        </div>
+      </div>
+    </div>
+    <div class="submenu" :class="{ visible: activeSubmenu === 'multimedia' }">
+      <div class="submenu-title">
+        <div class="title">{{ lang.ssAddMultimedia }}</div>
+        <div class="close-icon" @click="toggleSubmenu('multimedia')">
+          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 3">
+              <g id="Component 1">
+                <path id="Vector" d="M16 18L12 14L16 10" stroke="#9CA3AF" stroke-width="1.33333" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+      <div class="submenu-item-box2">
+        <div class="submenu-item" @click="handleToolClick('video')">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M38 8H10C7.79086 8 6 9.79086 6 12V36C6 38.2091 7.79086 40 10 40H38C40.2091 40 42 38.2091 42 36V12C42 9.79086 40.2091 8 38 8Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2" d="M20 18L32 24L20 30V18Z" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssVideo }}</span>
+        </div>
+        <!-- <div class="submenu-item">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector" d="M18 36V10L42 6V32" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2"
+                  d="M12 42C15.3137 42 18 39.3137 18 36C18 32.6863 15.3137 30 12 30C8.68629 30 6 32.6863 6 36C6 39.3137 8.68629 42 12 42Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3"
+                  d="M36 38C39.3137 38 42 35.3137 42 32C42 28.6863 39.3137 26 36 26C32.6863 26 30 28.6863 30 32C30 35.3137 32.6863 38 36 38Z"
+                  stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssAudio }}</span>
+        </div>
+        <div class="submenu-item">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M28 4H12C10.9391 4 9.92172 4.42143 9.17157 5.17157C8.42143 5.92172 8 6.93913 8 8V40C8 41.0609 8.42143 42.0783 9.17157 42.8284C9.92172 43.5786 10.9391 44 12 44H36C37.0609 44 38.0783 43.5786 38.8284 42.8284C39.5786 42.0783 40 41.0609 40 40V16L28 4Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2" d="M28 4V16H40" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3" d="M16 26H32" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_4" d="M16 34H28" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssDocument }}</span>
+        </div>
+        <div class="submenu-item">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M18 8H10C7.79086 8 6 9.79086 6 12V32C6 34.2091 7.79086 36 10 36H18C20.2091 36 22 34.2091 22 32V12C22 9.79086 20.2091 8 18 8Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2"
+                  d="M38 8H30C27.7909 8 26 9.79086 26 12V32C26 34.2091 27.7909 36 30 36H38C40.2091 36 42 34.2091 42 32V12C42 9.79086 40.2091 8 38 8Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3" d="M14 18V26" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_4" d="M34 18V26" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssDocumentSet }}</span>
+        </div> -->
+      </div>
+    </div>
+
+    <div v-if="exporting" class="parsing-modal">
+      <div class="parsing-content">
+        <div class="loading-spinner" v-if="exporting"></div>
+        <div class="success-icon" v-if="!exporting">
+          <svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 1">
+              <path id="Vector" d="M5.41675 14.084L9.75008 18.4173L20.5834 7.58398" stroke="#FF9300"
+                stroke-width="2.16667" stroke-linecap="round" stroke-linejoin="round" />
+            </g>
+          </svg>
+
+        </div>
+        <h3>{{ exporting ? lang.ssParsing : lang.ssExportCompleted }}</h3>
+        <p v-if="exporting">{{ lang.ssParsingFile }}{{ currentFileName }}</p>
+        <p v-if="!exporting">{{ lang.ssParsingCompleted }}</p>
+        <button class="close-btn2" @click="handleParsingClose">
+          {{ exporting ? lang.ssClose : lang.ssComplete }}
+        </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<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'
+import FileInput from '@/components/FileInput.vue'
+import AiChat from './componets/aiChat.vue'
+import { lang } from '@/main'
+import toolChoice from '@/assets/img/tool_choice.jpeg'
+import toolAnswer from '@/assets/img/tool_answer.png'
+
+interface ContentItem {
+  tool?: number
+  title?: string
+  url?: string
+  id?: string
+}
+
+
+
+const props = withDefaults(defineProps<{
+  defaultCollapsed?: boolean
+  userid?: string | null
+}>(), {
+  defaultCollapsed: false,
+  userid: null,
+})
+
+const emit = defineEmits<{
+  (e: 'toggle', collapsed: boolean): void
+}>()
+
+const isCollapsed = ref(props.defaultCollapsed)
+const activeSubmenu = ref<string | null>(null)
+const contentList = ref<ContentItem[]>([])
+const hoveredTool = ref<string | null>(null)
+const webpageUrl = ref('')
+const isLoading = ref(false)
+const isValidUrl = ref<boolean | null>(null) // null: 未输入, true: 有效, false: 无效
+
+const slidesStore = useSlidesStore()
+const { currentSlide } = storeToRefs(slidesStore)
+
+const { createFrameElement } = useCreateElement()
+const { createSlide, createSlideByTemplate } = useSlideHandler()
+
+
+
+import _ from 'lodash'
+
+const handleUrlInput = _.debounce(() => {
+  const url = webpageUrl.value.trim()
+  if (!url) {
+    isValidUrl.value = null
+  }
+  else {
+    // 简化的URL格式验证,支持各种复杂URL
+    const urlRegex = /^https?:\/\/.+/
+    isValidUrl.value = urlRegex.test(url)
+  }
+  console.log('URL输入:', webpageUrl.value, '验证结果:', isValidUrl.value)
+}, 300)
+const uploadWebpageLink = async () => {
+  if (!webpageUrl.value || isValidUrl.value !== true) {
+    // 可以添加提示信息
+    return
+  }
+
+  isLoading.value = true
+
+  // 模拟上传过程
+  // isLoading.value = false
+  // 上传成功后创建iframe元素
+
+  const isValid = await new Promise((resolve) => {
+    // 创建隐藏iframe
+    const iframe = document.createElement('iframe')
+    iframe.style.display = 'none'
+    iframe.src = webpageUrl.value
+    let finished = false
+    const timeout = setTimeout(() => {
+      if (finished) return
+      finished = true
+      // 超时,移除iframe,进入XHR判断
+      document.body.removeChild(iframe)
+      // 用XHR判断
+      const xhr = new XMLHttpRequest()
+      xhr.open('GET', iframe.src, true)
+      xhr.onreadystatechange = function() {
+        if (xhr.readyState === 4) {
+          if (xhr.status === 200) {
+            resolve(true)
+          }
+          else {
+            // 再试一次 getFile
+            getFile(iframe.src as any).then(res => {
+              if (res && res.data && res.data !== 1) {
+                resolve(true)
+              }
+              else {
+                resolve(false)
+              }
+            }).catch(() => {
+              resolve(false)
+            })
+          }
+        }
+      }.bind(this)
+      xhr.onerror = function() {
+        // 再试一次 getFile
+        getFile(iframe.src as any).then(res => {
+          if (res && res.data && res.data !== 1) {
+            resolve(true)
+          }
+          else {
+            resolve(false)
+          }
+        }).catch(() => {
+          resolve(false)
+        })
+      }.bind(this)
+      xhr.send()
+    }, 5000)
+
+    iframe.onload = function() {
+      if (finished) return
+      finished = true
+      clearTimeout(timeout)
+      try {
+        // 尝试访问contentWindow.document
+        const doc = iframe?.contentWindow?.document
+        document.body.removeChild(iframe)
+        resolve(true)
+      }
+      catch (e) {
+        // 跨域或其他异常,移除iframe,进入XHR判断
+        document.body.removeChild(iframe)
+        const xhr = new XMLHttpRequest()
+        xhr.open('GET', iframe.src, true)
+        xhr.onreadystatechange = function() {
+          if (xhr.readyState === 4) {
+            if (xhr.status === 200) {
+              resolve(true)
+            }
+            else {
+              // 再试一次 getFile
+              getFile(iframe.src as any).then(res => {
+                if (res && res.data && res.data !== 1) {
+                  resolve(true)
+                }
+                else {
+                  resolve(false)
+                }
+              }).catch(() => {
+                resolve(false)
+              })
+            }
+          }
+        }.bind(this)
+        xhr.onerror = function() {
+          // 再试一次 getFile
+          getFile(iframe.src as any).then(res => {
+            if (res && res.data && res.data !== 1) {
+              resolve(true)
+            }
+            else {
+              resolve(false)
+            }
+          }).catch(() => {
+            resolve(false)
+          })
+        }.bind(this)
+        xhr.send()
+      }
+    }
+    iframe.onerror = function() {
+      if (finished) return
+      finished = true
+      clearTimeout(timeout)
+      document.body.removeChild(iframe)
+      // iframe加载失败,进入XHR判断
+      const xhr = new XMLHttpRequest()
+      xhr.open('GET', iframe.src, true)
+      xhr.onreadystatechange = function() {
+        if (xhr.readyState === 4) {
+          if (xhr.status === 200) {
+            resolve(true)
+          }
+          else {
+            // 再试一次 getFile
+            getFile(iframe.src as any).then(res => {
+              if (res && res.data && res.data !== 1) {
+                resolve(true)
+              }
+              else {
+                resolve(false)
+              }
+            }).catch(() => {
+              resolve(false)
+            })
+          }
+        }
+      }.bind(this)
+      xhr.onerror = function() {
+        // 再试一次 getFile
+        getFile(iframe.src as any).then(res => {
+          if (res && res.data && res.data !== 1) {
+            resolve(true)
+          }
+          else {
+            resolve(false)
+          }
+        }).catch(() => {
+          resolve(false)
+        })
+      }.bind(this)
+      xhr.send()
+    }
+    document.body.appendChild(iframe)
+  })
+
+  if (!isValid) {
+    message.error(lang.ssCocoLinkTip)
+    isLoading.value = false
+    return
+  }
+  isLoading.value = false
+
+  createFrameElement(webpageUrl.value, 73) // 假设15是网页工具的类型
+  // 清空输入框和验证状态
+  webpageUrl.value = ''
+  isValidUrl.value = null
+}
+
+const toggleCollapse = () => {
+  isCollapsed.value = !isCollapsed.value
+  emit('toggle', isCollapsed.value)
+}
+
+const toggleSubmenu = (menu: string) => {
+  if (activeSubmenu.value === menu) {
+    activeSubmenu.value = null
+  }
+  else {
+    activeSubmenu.value = menu
+  }
+}
+
+import titlePage from './page/TitlePage.json'
+import ImagePage from './page/ImagePage.json'
+import ContentPage from './page/ContentPage.json'
+import ImageTextPage from './page/ImageTextPage.json'
+
+const handleToolClick = (tool: string) => {
+  interface ParentWindowWithToolList extends Window {
+    addTool?: (id: number) => void;
+    openVideoUploadDialog?: () => void;
+    openApplicationCenter?: () => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  console.log('点击工具:', tool)
+  if (tool === 'h5page') {
+    parentWindow?.addTool?.(73)
+  }
+  else if (tool === 'aiapp') {
+    parentWindow?.addTool?.(72)
+  }
+  else if (tool === 'createApp') {
+    if (lang.lang === 'cn') {
+      window.open('https://cloud.cocorobo.cn/admin.html?type=cocoflow1', '_blank')
+    }
+    else if (lang.lang === 'en') {
+      window.open('https://cloud.cocorobo.com/admin.html?type=cocoflow1', '_blank')
+    }
+    else if (lang.lang === 'hk') {
+      window.open('https://cloud.cocorobo.hk/admin.html?type=cocoflow1', '_blank')
+    }
+  }
+  else if (tool === 'video') {
+    parentWindow?.openVideoUploadDialog?.()
+  }
+  else if (tool === 'creative') {
+    parentWindow?.openApplicationCenter?.()
+  }
+  else if (tool === 'choice') {
+    parentWindow?.addTool?.(45)
+  }
+  else if (tool === 'qa') {
+    parentWindow?.addTool?.(15)
+  }
+  else if (tool === 'titlepage') {
+    createSlideByTemplate(titlePage)
+  }
+  else if (tool === 'ImagePage') {
+    createSlideByTemplate(ImagePage)
+  }
+  else if (tool === 'ContentPage') {
+    createSlideByTemplate(ContentPage)
+  }
+  else if (tool === 'ImageTextPage') {
+    createSlideByTemplate(ImageTextPage)
+  }
+  else if (tool === 'createWebpage') {
+    if (lang.lang === 'cn') {
+      window.open('https://cloud.cocorobo.cn/admin.html?type=cocoflow3', '_blank')
+    }
+    else if (lang.lang === 'en') {
+      window.open('https://cloud.cocorobo.com/admin.html?type=cocoflow3', '_blank')
+    }
+    else if (lang.lang === 'hk') {
+      window.open('https://cloud.cocorobo.hk/admin.html?type=cocoflow3', '_blank')
+    }
+  }
+  else if (tool === 'uploadWebpage') {
+    activeSubmenu.value = 'uploadWebpage'
+  }
+}
+
+const loadContentList = () => {
+  try {
+    interface ParentWindowWithToolList extends Window {
+      pptToolList?: ContentItem[]
+    }
+    const parentWindow = window.parent as ParentWindowWithToolList
+    contentList.value = parentWindow?.pptToolList || []
+  }
+  catch (error) {
+    console.error('加载内容列表失败:', error)
+    contentList.value = []
+  }
+}
+
+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, toolType: data.tool }
+      })
+    }
+  }
+  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;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.previewVideo?.(item)
+}
+
+const editContent = (item: ContentItem) => {
+  interface ParentWindowWithToolList extends Window {
+    toolBtn?: (action: number, id: string) => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.toolBtn?.(0, item.id || '')
+}
+
+const copyContent = (item: ContentItem) => {
+  interface ParentWindowWithToolList extends Window {
+    toolBtn?: (action: number, id: string) => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.toolBtn?.(1, item.id || '')
+}
+
+const deleteContent = (item: ContentItem) => {
+  interface ParentWindowWithToolList extends Window {
+    toolBtn?: (action: number, id: string) => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.toolBtn?.(2, item.id || '')
+}
+
+const getTypeLabel = (type?: number) => {
+  const typeMap: Record<number, string> = {
+    45: lang.ssChoiceQ,
+    15: lang.ssQATest,
+    72: lang.ssAiApp,
+    73: lang.ssHPage,
+    74: lang.ssVideo,
+    75: lang.ssBiliVideo,
+    76: lang.ssCreative,
+  }
+  return typeMap[type || 0] || lang.ssUnknown
+}
+
+const getTypeClass = (type?: number) => {
+  const classMap: Record<number, string> = {
+    45: 'type-choice',
+    15: 'type-question',
+    72: 'type-ai',
+    73: 'type-h5',
+    74: 'type-video',
+    75: 'type-bilibili',
+    76: 'type-app-center'
+  }
+  return classMap[type || 0] || 'type-default'
+}
+
+import useImport from '@/hooks/useImport'
+import message from '@/utils/message'
+const { importPPTXFile, exporting, getFile } = useImport()
+const currentFileName = ref('')
+const parsingStatus = ref<'parsing' | 'success'>('parsing')
+const parsingAbortController = ref<AbortController | null>(null)
+
+const handleFileUpload = async (files: FileList) => {
+  if (!files || files.length === 0) return
+
+  const file = files[0]
+  currentFileName.value = file.name
+
+  try {
+    // 创建AbortController用于取消操作
+    parsingAbortController.value = new AbortController()
+    const signal = parsingAbortController.value.signal
+
+    // 调用importPPTXFile并传入signal
+    await importPPTXFile(files, { signal })
+  }
+  catch (error) {
+    if (error instanceof DOMException && error.name === 'AbortError') {
+      console.log(lang.ssFileParseCancelled)
+    }
+    else {
+      console.error(lang.ssFileParseFailed, error)
+      message.error(lang.ssFileParseFailedRetry)
+    }
+  }
+}
+
+const handleParsingClose = () => {
+  if (exporting.value && parsingAbortController.value) {
+    parsingAbortController.value.abort()
+    exporting.value = false
+    parsingAbortController.value = null
+    // message.info(lang.ssParseCancelled)
+  }
+  else if (!exporting.value) {
+    emit('close')
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.collapsible-toolbar {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  background: #fff;
+  border-right: 1px solid #e5e7eb;
+  transition: width 0.3s ease;
+}
+
+.toolbar-content {
+  flex: 1;
+  overflow: hidden;
+  padding: 16px 8px;
+}
+
+.sidebar-content {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  width: 84px;
+  position: relative;
+}
+
+.sidebar-item {
+  width: 84px;
+  padding: 12px 8px;
+  border-radius: 12px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  cursor: pointer;
+  transition: all 0.2s;
+  position: relative;
+
+  &:hover {
+    background: #f3f4f6;
+  }
+
+  &:active {
+    background: #e5e7eb;
+  }
+
+  &.active {
+    background: #fff5e5;
+    box-shadow: 0 2px 8px rgba(40, 92, 245, 0.15);
+  }
+
+  &.active::after {
+    content: '';
+    position: absolute;
+    left: -8px;
+    top: 50%;
+    transform: translateY(-50%);
+    width: 4px;
+    height: 32px;
+    background: #FF9300;
+    border-radius: 0 2px 2px 0;
+  }
+}
+
+.item-icon {
+  width: 22px;
+  height: 22px;
+  flex-shrink: 0;
+  color: #6b7280;
+}
+
+.sidebar-item:hover .item-icon,
+.sidebar-item.active .item-icon {
+  color: #FF9300;
+}
+
+.item-label {
+  font-size: 11px;
+  font-weight: 500;
+  color: #6b7280;
+  text-align: center;
+}
+
+.sidebar-item:hover .item-label,
+.sidebar-item.active .item-label {
+  color: #FF9300;
+  font-weight: 600;
+}
+
+.submenu {
+  width: 0;
+  min-width: 0;
+  overflow: hidden;
+  // transition: all 0.3s ease;
+  background: #fff;
+  border-radius: 0 12px 12px 0;
+  z-index: 100;
+
+  &.visible {
+    width: 420px;
+    // padding: 16px;
+    border-left: 1px solid #E5E7EB;
+  }
+
+  @media screen and (max-width: 1920px) {
+    &.visible {
+      width: 360px;
+    }
+  }
+
+  @media screen and (max-width: 1440px) {
+    &.visible {
+      width: 320px;
+    }
+  }
+}
+
+.submenu-title {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  border-bottom: 1px solid #f0f0f0;
+  margin-bottom: 20px;
+  width: 100%;
+  box-sizing: border-box;
+  padding: 12px 15px;
+
+  .title {
+    font-size: 14px;
+    font-weight: 600;
+    color: #333;
+  }
+
+  .close-icon {
+    width: 28px;
+    height: 28px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 8px;
+    cursor: pointer;
+    transition: all 0.2s ease;
+
+    &:hover {
+      background-color: #f3f4f6;
+
+      svg {
+        stroke: #6b7280;
+      }
+    }
+
+    svg {
+      width: 25px;
+      height: 25px;
+      stroke: #9ca3af;
+      transition: all 0.2s ease;
+    }
+  }
+}
+
+.submenu-content {
+  width: 100%;
+  height: calc(100% - 50px);
+  box-sizing: border-box;
+  overflow: hidden;
+}
+
+
+.submenu-img {
+  width: calc(100% - 30px);
+  margin: 0 auto;
+  height: 130px;
+  display: flex;
+  object-fit: cover;
+  border-radius: 10px;
+  margin-bottom: 20px;
+}
+
+.submenu-panel {
+  width: calc(100% - 30px);
+  height: 130px;
+  margin: 0 auto;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 15px 0;
+  margin-bottom: 20px;
+  background: #FFFAF0;
+  border-radius: 10px;
+  box-sizing: border-box;
+
+  svg,
+  img {
+    width: 120px;
+    height: auto;
+    object-fit: cover;
+  }
+
+  .detail {
+    font-size: 12px;
+    font-weight: 500;
+    color: #6B7280;
+    text-align: center;
+    margin-top: 5px;
+  }
+}
+
+/* 淡入淡出过渡效果 */
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.3s ease, transform 0.3s ease;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+  transform: scale(0.95);
+}
+
+.fade-enter-to,
+.fade-leave-from {
+  opacity: 1;
+  transform: scale(1);
+}
+
+.submenu-item-box {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 12px;
+  padding: 0 15px;
+
+  .submenu-item {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 12px;
+    cursor: pointer;
+    transition: all 0.2s ease;
+    font-size: 14px;
+    color: #333;
+    border-radius: 12px;
+    background: #fff;
+    border: 1px solid #e5e7eb;
+
+    &:hover {
+      background-color: #fffdfa;
+      border-color: #fffdfa;
+      color: #FF9300;
+      transform: translateY(-1px);
+      box-shadow: 0 2px 8px rgba(40, 92, 245, 0.1);
+    }
+
+    &:active {
+      background-color: #fffdfa;
+      transform: translateY(0);
+    }
+  }
+
+  .submenu-icon {
+    width: 20px;
+    height: 20px;
+    flex-shrink: 0;
+    color: #6b7280;
+    background: #fffaf0;
+    border-radius: 10px;
+    padding: 8px;
+    border: 1px solid #e5e7eb;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .submenu-item:hover .submenu-icon {
+    color: #FF9300;
+    border-color: #FF9300;
+    box-shadow: 0 0 0 2px rgba(255, 147, 0, 0.1);
+  }
+
+  .submenu-label {
+    font-size: 14px;
+    font-weight: 500;
+    color: #333;
+    flex: 1;
+  }
+
+  .submenu-item:hover .submenu-label {
+    color: #FF9300;
+    font-weight: 600;
+  }
+}
+
+.submenu-item-box2 {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 12px;
+  padding: 0 15px;
+
+  .submenu-item {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 12px;
+    cursor: pointer;
+    transition: all 0.2s ease;
+    font-size: 14px;
+    color: #333;
+    border-radius: 12px;
+    border: 1px solid #e5e7eb;
+    flex-direction: column;
+    background: #fafbfc;
+
+    &:hover {
+      background-color: #fffdfa;
+      border-color: #fffdfa;
+      color: #FF9300;
+      transform: translateY(-1px);
+      box-shadow: 0 2px 8px rgba(40, 92, 245, 0.1);
+    }
+
+    &:active {
+      background-color: #fffdfa;
+      transform: translateY(0);
+    }
+  }
+
+  .submenu-icon {
+    flex-shrink: 0;
+    color: #6b7280;
+    background: #fff;
+    border-radius: 10px;
+    padding: 25px;
+    border: 1px solid #e5e7eb;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 95%;
+
+    svg {
+      width: 40px;
+      height: 40px;
+      color: #d7dbe0;
+    }
+  }
+
+  .submenu-item:hover .submenu-icon {
+    svg {
+      color: #FF9300;
+    }
+
+    border-color: #FF9300;
+    box-shadow: 0 0 0 2px rgba(255, 147, 0, 0.1);
+  }
+
+  .submenu-label {
+    font-size: 14px;
+    font-weight: 600;
+    color: #333;
+    flex: 1;
+  }
+
+  .submenu-item:hover .submenu-label {
+    color: #FF9300;
+    font-weight: 600;
+  }
+}
+
+.submenu-upload {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 12px;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  font-size: 14px;
+  color: #333;
+  border-radius: 12px;
+  border: 1px solid #e5e7eb;
+  background: #fafbfc;
+  width: calc(100% - 30px);
+  margin: 10px auto 0;
+  justify-content: center;
+
+  .submenu-icon {
+    width: 20px;
+    height: 20px;
+
+    svg {
+      color: #d7dbe0;
+      width: 20px;
+      height: 20px;
+    }
+  }
+
+  .submenu-label {
+    font-size: 14px;
+    font-weight: 600;
+    color: #333;
+
+  }
+
+  &:hover {
+    background-color: #fffdfa;
+    border-color: #fffdfa;
+    color: #FF9300;
+    transform: translateY(-1px);
+    box-shadow: 0 2px 8px rgba(40, 92, 245, 0.1);
+
+
+    .submenu-icon {
+
+      svg {
+        color: #FF9300;
+      }
+    }
+
+    .submenu-label {
+      color: #FF9300;
+    }
+  }
+}
+
+
+.parsing-modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+
+  .parsing-content {
+    background: white;
+    border-radius: 12px;
+    padding: 24px;
+    width: 400px;
+    text-align: center;
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+
+    .loading-spinner {
+      width: 48px;
+      height: 48px;
+      border: 4px solid #f0f0f0;
+      border-top: 4px solid #FF9300;
+      border-radius: 50%;
+      margin: 0 auto 20px;
+      animation: spin 1s linear infinite;
+    }
+
+    .success-icon {
+      width: 48px;
+      height: 48px;
+      margin: 0 auto 20px;
+      background: #FFFAF0;
+      border-radius: 5px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      color: white;
+      font-size: 24px;
+      font-weight: bold;
+    }
+
+    h3 {
+      font-size: 20px;
+      font-weight: 600;
+      color: #333;
+      margin: 0 0 12px;
+    }
+
+    p {
+      font-size: 14px;
+      color: #666;
+      margin: 0 0 24px;
+    }
+
+    .close-btn2 {
+      background: #FF9300;
+      color: white;
+      border: none;
+      border-radius: 8px;
+      padding: 12px 24px;
+      font-size: 14px;
+      font-weight: 500;
+      cursor: pointer;
+      width: 100%;
+      transition: all 0.3s;
+
+      &:hover {
+        background: #e68a00;
+      }
+    }
+  }
+
+  @keyframes spin {
+    0% {
+      transform: rotate(0deg);
+    }
+
+    100% {
+      transform: rotate(360deg);
+    }
+  }
+
+  .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;
+    }
+  }
+}
+
+.line_box {
+  .webpage-link-container {
+    padding: 20px;
+    // text-align: center;
+
+    .webpage-link-title {
+      margin: 0 0 16px;
+      font-size: 14px;
+      font-weight: 600;
+      color: #333;
+    }
+
+    .webpage-link-input {
+      width: 100%;
+      padding: 12px 12px;
+      border: 1px solid #d9d9d9;
+      border-radius: 8px;
+      font-size: 14px;
+      margin-bottom: 16px;
+      transition: all 0.3s;
+      box-sizing: border-box;
+
+      &:focus {
+        outline: none;
+        border-color: #FF9300;
+        background: #fff8f0;
+      }
+    }
+
+    .webpage-link-button {
+      text-align: center;
+      padding: 10px 24px;
+      border: none;
+      border-radius: 4px;
+      font-size: 14px;
+      font-weight: 500;
+      cursor: pointer;
+      transition: all 0.3s;
+      background-color: #FF9300;
+      color: white;
+      margin: 0 auto;
+      display: block;
+
+      &:hover:not(:disabled) {
+        background-color: #e68a00;
+      }
+
+      &:disabled {
+        opacity: 0.5;
+        cursor: not-allowed;
+      }
+
+      &.loading {
+        cursor: not-allowed;
+        opacity: 0.8;
+      }
+
+      &.error {
+        background-color: #ff4d4f;
+        color: white;
+
+        &:hover:not(:disabled) {
+          background-color: #ff7875;
+        }
+      }
+    }
+  }
+}
+</style>

+ 48 - 0
src/components/CollapsibleToolbar/page/ContentPage.json

@@ -0,0 +1,48 @@
+{
+    "id": "test-slide-1",
+    "elements": [
+        {
+            "type": "text",
+            "id": "Wy9M1HzdpW",
+            "left": 104.88621151271751,
+            "top": 283.9625167336011,
+            "width": 403.7483266398929,
+            "height": 76.03748326639892,
+            "content": "<ol style=\"font-size: 18px;color: #333;\"><li><p style=\"\"><span style=\"font-size: 18px;\">单击此处修改文本。&nbsp;Click here to edit the text.</span></p></li><li><p style=\"\"><span style=\"font-size: 18px;\">单击此处修改文本。&nbsp;Click here to edit the text.</span></p></li><li><p style=\"\"><span style=\"font-size: 18px;\">单击此处修改文本。&nbsp;Click here to edit the text.</span></p></li><li><p style=\"\"><span style=\"font-size: 18px;\">单击此处修改文本。&nbsp;Click here to edit the text.</span></p></li><li><p style=\"\"><span style=\"font-size: 18px;\">单击此处修改文本。&nbsp;Click here to edit the text.</span></p></li><li><p style=\"\"><span style=\"font-size: 18px;\">单击此处修改文本。&nbsp;Click here to edit the text.</span></p></li></ol><p style=\"\"><br class=\"ProseMirror-trailingBreak\"></p>",
+            "rotate": 0,
+            "defaultFontName": "",
+            "defaultColor": "#333",
+            "vertical": false
+        },
+        {
+            "type": "text",
+            "id": "JBGGLlQU2W",
+            "left": 104.88621151271751,
+            "top": 57.28915662650606,
+            "width": 747.5234270414993,
+            "height": 76.03748326639892,
+            "content": "<p style=\"\"><span style=\"font-size: 36px;\">单击此处修改标题</span></p><p style=\"\"><span style=\"font-size: 36px;\"> Click here to edit the title</span></p>",
+            "rotate": 0,
+            "defaultFontName": "",
+            "defaultColor": "#333",
+            "vertical": false
+        },
+        {
+            "type": "text",
+            "id": "6209GCogeQ",
+            "left": 682.4899598393573,
+            "top": 283.9625167336011,
+            "width": 403.7483266398929,
+            "height": 76.03748326639892,
+            "content": "<ul style=\"list-style-type: circle;font-size: 18px;color: rgb(51, 51, 51);\"><li><p style=\"\"><span style=\"font-size: 18px;\"><span style=\"color: rgb(51, 51, 51);\">单击此处修改文本。&nbsp;Click here to edit the text.</span></span></p></li><li><p style=\"\"><span style=\"font-size: 18px;\"><span style=\"color: rgb(51, 51, 51);\">单击此处修改文本。&nbsp;Click here to edit the text.</span></span></p></li><li><p style=\"\"><span style=\"font-size: 18px;\"><span style=\"color: rgb(51, 51, 51);\">单击此处修改文本。&nbsp;Click here to edit the text.</span></span></p></li><li><p style=\"\"><span style=\"font-size: 18px;\"><span style=\"color: rgb(51, 51, 51);\">单击此处修改文本。&nbsp;Click here to edit the text.</span></span></p></li><li><p style=\"\"><span style=\"font-size: 18px;\"><span style=\"color: rgb(51, 51, 51);\">单击此处修改文本。&nbsp;Click here to edit the text.</span></span></p></li><li><p style=\"\"><span style=\"font-size: 18px;\"><span style=\"color: rgb(51, 51, 51);\">单击此处修改文本。&nbsp;Click here to edit the text.</span></span></p></li></ul>",
+            "rotate": 0,
+            "defaultFontName": "",
+            "defaultColor": "#333",
+            "vertical": false
+        }
+    ],
+    "background": {
+        "type": "solid",
+        "color": "#fff"
+    }
+}

+ 38 - 0
src/components/CollapsibleToolbar/page/ImagePage.json

@@ -0,0 +1,38 @@
+{
+    "id": "test-slide-1",
+    "elements": [
+        {
+            "type": "text",
+            "id": "Wy9M1HzdpW",
+            "left": 266.2,
+            "top": 593.4738955823293,
+            "width": 747.5234270414993,
+            "height": 76.03748326639892,
+            "content": "<p style=\"text-align: center;text-align-last: center;\"><span style=\"font-size: 18px;\">这是这幅图片的描述。This is the description of this picture. 這是這幅圖片的描述。</span></p>",
+            "rotate": 0,
+            "defaultFontName": "",
+            "defaultColor": "#333",
+            "vertical": false
+        },
+        {
+            "type": "shape",
+            "id": "-SplTJKvh_",
+            "left": 254.41954484605088,
+            "top": 109.40428380187416,
+            "width": 771.0843373493975,
+            "height": 430.5220883534136,
+            "viewBox": [
+                200,
+                200
+            ],
+            "path": "M 0 0 L 200 0 L 200 200 L 0 200 Z",
+            "fill": "#5b9bd5",
+            "fixedRatio": false,
+            "rotate": 0
+        }
+    ],
+    "background": {
+        "type": "solid",
+        "color": "#fff"
+    }
+}

+ 64 - 0
src/components/CollapsibleToolbar/page/ImageTextPage.json

@@ -0,0 +1,64 @@
+{
+    "id": "test-slide-1",
+    "elements": [
+        {
+            "type": "text",
+            "id": "Wy9M1HzdpW",
+            "left": 104.88621151271751,
+            "top": 283.9625167336011,
+            "width": 403.7483266398929,
+            "height": 76.03748326639892,
+            "content": "<ol style=\"font-size: 18px;color: #333;\"><li><p style=\"\"><span style=\"font-size: 18px;\">单击此处修改文本。&nbsp;Click here to edit the text.</span></p></li><li><p style=\"\"><span style=\"font-size: 18px;\">单击此处修改文本。&nbsp;Click here to edit the text.</span></p></li><li><p style=\"\"><span style=\"font-size: 18px;\">单击此处修改文本。&nbsp;Click here to edit the text.</span></p></li><li><p style=\"\"><span style=\"font-size: 18px;\">单击此处修改文本。&nbsp;Click here to edit the text.</span></p></li><li><p style=\"\"><span style=\"font-size: 18px;\">单击此处修改文本。&nbsp;Click here to edit the text.</span></p></li><li><p style=\"\"><span style=\"font-size: 18px;\">单击此处修改文本。&nbsp;Click here to edit the text.</span></p></li></ol><p style=\"\"><br class=\"ProseMirror-trailingBreak\"></p>",
+            "rotate": 0,
+            "defaultFontName": "",
+            "defaultColor": "#333",
+            "vertical": false
+        },
+        {
+            "type": "text",
+            "id": "JBGGLlQU2W",
+            "left": 104.88621151271751,
+            "top": 57.28915662650606,
+            "width": 747.5234270414993,
+            "height": 76.03748326639892,
+            "content": "<p style=\"\"><span style=\"font-size: 36px;\">单击此处修改标题</span></p><p style=\"\"><span style=\"font-size: 36px;\"> Click here to edit the title</span></p>",
+            "rotate": 0,
+            "defaultFontName": "",
+            "defaultColor": "#333",
+            "vertical": false
+        },
+        {
+            "type": "shape",
+            "id": "yn3lcZe3MS",
+            "left": 710.6827309236947,
+            "top": 243.2730923694779,
+            "width": 358.7684069611781,
+            "height": 268.80856760374826,
+            "viewBox": [
+                200,
+                200
+            ],
+            "path": "M 0 0 L 200 0 L 200 200 L 0 200 Z",
+            "fill": "#5b9bd5",
+            "fixedRatio": false,
+            "rotate": 0
+        },
+        {
+            "type": "text",
+            "id": "TZWTJZ70aj",
+            "left": 667.1753681392235,
+            "top": 554.7523427041499,
+            "width": 445.7831325301205,
+            "height": 50,
+            "content": "<p style=\"text-align: center;text-align-last: center;\">这是这幅图片的描述。This is the description of this picture. </p>",
+            "rotate": 0,
+            "defaultFontName": "",
+            "defaultColor": "#333",
+            "vertical": false
+        }
+    ],
+    "background": {
+        "type": "solid",
+        "color": "#fff"
+    }
+}

+ 36 - 0
src/components/CollapsibleToolbar/page/TitlePage.json

@@ -0,0 +1,36 @@
+{
+    "id": "test-slide-1",
+    "elements": [
+        {
+            "type": "text",
+            "id": "qjXk3C3iwg",
+            "left": 119.51807228915663,
+            "top": 181.73012048192768,
+            "width": 1040.9638554216867,
+            "height": 221.68674698795178,
+            "content": "<p style=\"text-align: center;text-align-last: center;\"><span style=\"font-size: 104px;\"><span style=\"color: #000000;\">单击此处修改标题</span></span></p>",
+            "rotate": 0,
+            "defaultFontName": "",
+            "defaultColor": "#333",
+            "vertical": false,
+            "lineHeight": 1
+        },
+        {
+            "type": "text",
+            "id": "hm9gt3tnn-",
+            "left": 161.82061579651946,
+            "top": 416.7670682730924,
+            "width": 956.3587684069611,
+            "height": 157.429718875502,
+            "content": "<p style=\"text-align: center;text-align-last: center;\"><span style=\"font-size: 80px;\">单击此处修改副标题</span></p>",
+            "rotate": 0,
+            "defaultFontName": "",
+            "defaultColor": "#333",
+            "vertical": false
+        }
+    ],
+    "background": {
+        "type": "solid",
+        "color": "#fff"
+    }
+}

+ 91 - 0
src/components/ConfirmDialog.vue

@@ -0,0 +1,91 @@
+<template>
+  <Modal
+    :visible="visible"
+    :width="width"
+    :closeButton="false"
+    :closeOnClickMask="closeOnClickMask"
+    :closeOnEsc="closeOnEsc"
+    @update:visible="handleVisibleChange"
+  >
+    <div class="confirm-dialog">
+      <div class="confirm-dialog__title">{{ title }}</div>
+      <div class="confirm-dialog__content">
+        <slot>{{ content }}</slot>
+      </div>
+      <div class="confirm-dialog__footer">
+        <Button type="default" @click="handleCancel">{{ cancelText }}</Button>
+        <Button type="primary" @click="handleConfirm">{{ confirmText }}</Button>
+      </div>
+    </div>
+  </Modal>
+</template>
+
+<script lang="ts" setup>
+import Modal from '@/components/Modal.vue'
+import Button from '@/components/Button.vue'
+
+const props = withDefaults(defineProps<{
+  visible: boolean
+  title?: string
+  content?: string
+  confirmText?: string
+  cancelText?: string
+  width?: number
+  closeOnClickMask?: boolean
+  closeOnEsc?: boolean
+}>(), {
+  title: '提示',
+  content: '',
+  confirmText: '确认',
+  cancelText: '取消',
+  width: 420,
+  closeOnClickMask: false,
+  closeOnEsc: false,
+})
+
+const emit = defineEmits<{
+  (event: 'update:visible', payload: boolean): void
+  (event: 'confirm'): void
+  (event: 'cancel'): void
+}>()
+
+const handleVisibleChange = (val: boolean) => {
+  emit('update:visible', val)
+}
+
+const handleConfirm = () => {
+  emit('confirm')
+  emit('update:visible', false)
+}
+
+const handleCancel = () => {
+  emit('cancel')
+  emit('update:visible', false)
+}
+</script>
+
+<style lang="scss" scoped>
+.confirm-dialog {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.confirm-dialog__title {
+  font-size: 16px;
+  font-weight: 500;
+  color: #333;
+}
+
+.confirm-dialog__content {
+  font-size: 14px;
+  color: #666;
+  line-height: 1.5;
+}
+
+.confirm-dialog__footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+}
+</style>

+ 4 - 2
src/components/Contextmenu/MenuContent.vue

@@ -1,5 +1,7 @@
 <template>
-  <ul class="menu-content">
+  <ul class="menu-content" 
+    :style="{width: lang.lang === 'en' ? '260px' : '180px'}"
+  >
     <template v-for="(menu, index) in menus" :key="menu.text || index">
       <li
         v-if="!menu.hide"
@@ -32,7 +34,7 @@
 
 <script lang="ts" setup>
 import type { ContextmenuItem } from './types'
-
+import { lang } from '@/main'
 defineProps<{
   menus: ContextmenuItem[]
   handleClickMenuItem: (item: ContextmenuItem) => void

+ 426 - 0
src/components/CreateCourseDialog.vue

@@ -0,0 +1,426 @@
+<template>
+  <div class="create-course-dialog">
+    <div class="dialog-header">
+      <button class="close-btn" @click="handleClose">
+        <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>{{ lang.ssCreateCourse }}</h2>
+      <p class="subtitle">{{ lang.ssCreateCourseSubtitle }}</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>{{ lang.ssCreateFromAI }}</h3>
+          <p>{{ lang.ssAIGenerateContent }}</p>
+          <div class="coming-soon">{{ lang.ssComingSoon }}</div>
+        </div>
+        <FileInput accept="application/vnd.openxmlformats-officedocument.presentationml.presentation"
+          @change="handleFileUpload">
+          <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>{{ lang.ssUploadLocalFile }}</h3>
+            <p>{{ lang.ssUploadPPTFile }}</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>{{ lang.ssImportFromLibrary }}</h3>
+          <p>{{ lang.ssSelectExistingContent }}</p>
+          <div class="coming-soon">{{ lang.ssComingSoon }}</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>{{ lang.ssCreateBlank }}</h3>
+          <p>{{ lang.ssStartFromScratch }}</p>
+        </div>
+      </div>
+    </div>
+
+    <!-- 解析状态弹窗 -->
+    <div v-if="exporting" class="parsing-modal">
+      <div class="parsing-content">
+        <div class="loading-spinner" v-if="exporting"></div>
+        <div class="success-icon" v-if="!exporting">
+          <svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 1">
+              <path id="Vector" d="M5.41675 14.084L9.75008 18.4173L20.5834 7.58398" stroke="#FF9300"
+                stroke-width="2.16667" stroke-linecap="round" stroke-linejoin="round" />
+            </g>
+          </svg>
+
+        </div>
+        <h3>{{ exporting ? lang.ssParsing : lang.ssExportCompleted }}</h3>
+        <p v-if="exporting">{{ lang.ssParsingFile }}{{ currentFileName }}</p>
+        <p v-if="!exporting">{{ lang.ssParsingCompleted }}</p>
+        <button class="close-btn2" @click="handleParsingClose">
+          {{ exporting ? lang.ssClose : lang.ssComplete }}
+        </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+import useImport from '@/hooks/useImport'
+import FileInput from '@/components/FileInput.vue'
+import message from '@/utils/message'
+import { lang } from '@/main'
+
+const emit = defineEmits<{
+  (e: 'close'): void
+  (e: 'select', option: string): void
+}>()
+
+const { importPPTXFile, exporting } = useImport()
+const currentFileName = ref('')
+const parsingStatus = ref<'parsing' | 'success'>('parsing')
+const parsingAbortController = ref<AbortController | null>(null)
+
+const handleOptionClick = (option: string) => {
+  emit('select', option)
+  emit('close')
+}
+
+const handleClose = () => {
+  interface ParentWindowWithToolList extends Window {
+    goBack?: () => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.goBack?.()
+}
+
+const handleFileUpload = async (files: FileList) => {
+  if (!files || files.length === 0) return
+
+  const file = files[0]
+  currentFileName.value = file.name
+
+  try {
+    // 创建AbortController用于取消操作
+    parsingAbortController.value = new AbortController()
+    const signal = parsingAbortController.value.signal
+
+    // 调用importPPTXFile并传入signal
+    await importPPTXFile(files, { signal, onclose: () => emit('close') })
+  }
+  catch (error) {
+    if (error instanceof DOMException && error.name === 'AbortError') {
+      console.log('文件解析已取消')
+    }
+    else {
+      console.error('文件解析失败:', error)
+      // message.error('文件解析失败,请重试')
+      message.error(lang.ssFileParseFailedRetry)
+    }
+  }
+}
+
+const handleParsingClose = () => {
+  if (exporting.value && parsingAbortController.value) {
+    parsingAbortController.value.abort()
+    exporting.value = false
+    parsingAbortController.value = null
+    // message.info('解析已取消')
+  }
+  else if (!exporting.value) {
+    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;
+          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;
+          }
+        }
+
+        .option-icon {
+          width: 48px;
+          height: 48px;
+          background: #fff;
+          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;
+        }
+      }
+    }
+  }
+
+  .parsing-modal {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background: rgba(0, 0, 0, 0.5);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    z-index: 1000;
+
+    .parsing-content {
+      background: white;
+      border-radius: 12px;
+      padding: 40px;
+      text-align: center;
+      max-width: 400px;
+      width: 90%;
+
+      .loading-spinner {
+        width: 48px;
+        height: 48px;
+        border: 4px solid #f3f3f3;
+        border-top: 4px solid #FF9300;
+        border-radius: 50%;
+        animation: spin 1s linear infinite;
+        margin: 0 auto 20px;
+      }
+
+      .success-icon {
+        width: 48px;
+        height: 48px;
+        margin: 0 auto 20px;
+        background: #FFFAF0;
+        border-radius: 5px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        color: white;
+        font-size: 24px;
+        font-weight: bold;
+      }
+
+      h3 {
+        font-size: 20px;
+        font-weight: 600;
+        color: #333;
+        margin: 0 0 12px;
+      }
+
+      p {
+        font-size: 14px;
+        color: #666;
+        margin: 0 0 24px;
+      }
+
+      .close-btn2 {
+        background: #FF9300;
+        color: white;
+        border: none;
+        border-radius: 8px;
+        padding: 12px 24px;
+        font-size: 14px;
+        font-weight: 500;
+        cursor: pointer;
+        width: 100%;
+        transition: all 0.3s;
+
+        &:hover {
+          background: #e68a00;
+        }
+      }
+    }
+
+    @keyframes spin {
+      0% {
+        transform: rotate(0deg);
+      }
+
+      100% {
+        transform: rotate(360deg);
+      }
+    }
+
+    .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;
+      }
+    }
+  }
+
+
+}
+</style>

+ 47 - 0
src/components/FileInput2.vue

@@ -0,0 +1,47 @@
+<template>
+  <div class="file-input" @click="handleClick()">
+    <slot></slot>
+    <input 
+      class="input"
+      type="file" 
+      name="upload" 
+      ref="inputRef" 
+      :accept="accept" 
+      multiple
+      @change="$event => handleChange($event)"
+      @click.stop
+    >
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { useTemplateRef } from 'vue'
+
+withDefaults(defineProps<{
+  accept?: string
+}>(), {
+  accept: 'image/*',
+})
+
+const emit = defineEmits<{
+  (event: 'change', payload: FileList): void
+}>()
+
+const inputRef = useTemplateRef<HTMLInputElement>('inputRef')
+
+const handleClick = () => {
+  if (!inputRef.value) return
+  inputRef.value.value = ''
+  inputRef.value.click()
+}
+const handleChange = (e: Event) => {
+  const files = (e.target as HTMLInputElement).files
+  if (files) emit('change', files)
+}
+</script>
+
+<style lang="scss" scoped>
+.input {
+  display: none;
+}
+</style>

+ 9 - 8
src/components/LaTeXEditor/index.vue

@@ -3,10 +3,10 @@
     <div class="container">
       <div class="left">
         <div class="input-area">
-          <TextArea v-model:value="latex" placeholder="输入 LaTeX 公式" ref="textAreaRef" />
+          <TextArea v-model:value="latex" :placeholder="lang.ssLatexPh" ref="textAreaRef" />
         </div>
         <div class="preview">
-          <div class="placeholder" v-if="!latex">公式预览</div>
+          <div class="placeholder" v-if="!latex">{{ lang.ssFormulaPrev }}</div>
           <div class="preview-content" v-else>
             <FormulaContent
               :width="518"
@@ -16,7 +16,7 @@
           </div>
         </div>
       </div>
-      <div class="right">
+      <div class="right" :style="{ width: lang.lang === 'en' ? '350px' : '280px' }">
         <Tabs 
           :tabs="tabs" 
           v-model:value="toolbarState" 
@@ -52,8 +52,8 @@
       </div>
     </div>
     <div class="footer">
-      <Button class="btn" @click="emit('close')">取消</Button>
-      <Button class="btn" type="primary" @click="update()">确定</Button>
+      <Button class="btn" @click="emit('close')">{{ lang.ssCancel }}</Button>
+      <Button class="btn" type="primary" @click="update()">{{ lang.ssInsert }}</Button>
     </div>
   </div>
 </template>
@@ -63,6 +63,7 @@ import { computed, onMounted, ref, useTemplateRef } from 'vue'
 import { hfmath } from './hfmath'
 import { FORMULA_LIST, SYMBOL_LIST } from '@/configs/latex'
 import message from '@/utils/message'
+import { lang } from '@/main'
 
 import FormulaContent from './FormulaContent.vue'
 import SymbolContent from './SymbolContent.vue'
@@ -76,8 +77,8 @@ interface TabItem {
 }
 
 const tabs: TabItem[] = [
-  { label: '常用符号', key: 'symbol' },
-  { label: '预置公式', key: 'formula' },
+  { label: lang.ssLatexSym, key: 'symbol' },
+  { label: lang.ssLatexPreset, key: 'formula' },
 ]
 
 interface LatexResult {
@@ -120,7 +121,7 @@ onMounted(() => {
 })
 
 const update = () => {
-  if (!latex.value) return message.error('公式不能为空')
+  if (!latex.value) return message.error(lang.ssLatexEmpty)
 
   const eq = new hfmath(latex.value)
   const pathd = eq.pathd({})

+ 2 - 1
src/components/Message.vue

@@ -125,9 +125,10 @@ defineExpose({
   }
   .content {
     width: 100%;
+    padding: unset;
   }
   .description {
-    line-height: 1.5;
+    line-height: 1.15;
     color: $textColor;
   }
   .title + .description {

+ 6 - 2
src/components/Modal.vue

@@ -1,13 +1,13 @@
 <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"
           @before-enter="contentVisible = true"
         >
-          <div class="modal-content" v-show="visible" :style="contentStyle">
+          <div class="modal-content" v-show="visible" :style="lang.lang === 'en' ? { width: '950px' } : contentStyle">
             <span class="close-btn" v-if="closeButton" @click="close()"><IconClose /></span>
             <slot v-if="contentVisible"></slot>
           </div>
@@ -20,6 +20,7 @@
 <script lang="ts" setup>
 import { computed, nextTick, ref, watch, useTemplateRef, type CSSProperties } from 'vue'
 import { icons } from '@/plugins/icon'
+import { lang } from '@/main'
 
 const { IconClose } = icons
 
@@ -30,11 +31,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 +48,7 @@ const emit = defineEmits<{
 }>()
 
 const contentVisible = ref(false)
+const modalClass = computed(() => props.class)
 
 const contentStyle = computed(() => {
   return {

+ 220 - 0
src/components/MoveablePanel2.vue

@@ -0,0 +1,220 @@
+<template>
+  <div 
+    class="moveable-panel"
+    ref="moveablePanelRef"
+    :style="{
+      width: w + 'px',
+      height: h ? h + 'px' : 'auto',
+      left: x + 'px',
+      top: y + 'px',
+    }"
+  >
+    <template v-if="title">
+      <div class="header" @mousedown="$event => startMove($event)">
+        <div class="title">{{title}}</div>
+        <div class="close-btn" @click="emit('close')"><IconClose /></div>
+      </div>
+
+      <div class="content">
+        <slot></slot>
+      </div>
+    </template>
+
+    <div v-else class="content" @mousedown="$event => startMove($event)">
+      <slot></slot>
+    </div>
+
+    <div class="resizer" v-if="resizeable" @mousedown="$event => startResize($event)"></div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref, useTemplateRef } from 'vue'
+
+const props = withDefaults(defineProps<{
+  width: number
+  height: number
+  minWidth?: number
+  minHeight?: number
+  maxWidth?: number
+  maxHeight?: number
+  left?: number
+  top?: number
+  title?: string
+  moveable?: boolean
+  resizeable?: boolean
+}>(), {
+  minWidth: 20,
+  minHeight: 20,
+  maxWidth: 500,
+  maxHeight: 500,
+  left: 10,
+  top: 10,
+  title: '',
+  moveable: true,
+  resizeable: false,
+})
+
+const emit = defineEmits<{
+  (event: 'close'): void
+}>()
+
+const x = ref(0)
+const y = ref(0)
+const w = ref(0)
+const h = ref(0)
+const moveablePanelRef = useTemplateRef<HTMLElement>('moveablePanelRef')
+const realHeight = computed(() => {
+  if (!h.value) {
+    return moveablePanelRef.value?.clientHeight || 0
+  }
+  return h.value
+})
+
+onMounted(() => {
+  if (props.left >= 0) x.value = props.left
+  else x.value = document.body.clientWidth + props.left - props.width
+
+  if (props.top >= 0) y.value = props.top
+  else y.value = document.body.clientHeight + props.top - (props.height || realHeight.value)
+
+  w.value = props.width
+  h.value = props.height
+})
+
+const startMove = (e: MouseEvent) => {
+  if (!props.moveable) return
+
+  let isMouseDown = true
+
+  const windowWidth = document.body.clientWidth
+  const clientHeight = document.body.clientHeight
+
+  const startPageX = e.pageX
+  const startPageY = e.pageY
+
+  const originLeft = x.value
+  const originTop = y.value
+
+  document.onmousemove = e => {
+    if (!isMouseDown) return
+
+    const moveX = e.pageX - startPageX
+    const moveY = e.pageY - startPageY
+
+    let left = originLeft + moveX
+    let top = originTop + moveY
+
+    if (left < 0) left = 0
+    if (top < 0) top = 0
+    if (left + w.value > windowWidth) left = windowWidth - w.value
+    if (top + realHeight.value > clientHeight) top = clientHeight - realHeight.value
+
+    x.value = left
+    y.value = top
+  }
+  document.onmouseup = () => {
+    isMouseDown = false
+
+    document.onmousemove = null
+    document.onmouseup = null
+  }
+}
+
+const startResize = (e: MouseEvent) => {
+  if (!props.resizeable) return
+
+  let isMouseDown = true
+
+  const startPageX = e.pageX
+  const startPageY = e.pageY
+
+  const originWidth = w.value
+  const originHeight = h.value
+
+  document.onmousemove = e => {
+    if (!isMouseDown) return
+
+    const moveX = e.pageX - startPageX
+    const moveY = e.pageY - startPageY
+
+    let width = originWidth + moveX
+    let height = originHeight + moveY
+
+    if (width < props.minWidth) width = props.minWidth
+    if (height < props.minHeight) height = props.minHeight
+    if (width > props.maxWidth) width = props.maxWidth
+    if (height > props.maxHeight) height = props.maxHeight
+
+    w.value = width
+    h.value = height
+  }
+  document.onmouseup = () => {
+    isMouseDown = false
+
+    document.onmousemove = null
+    document.onmouseup = null
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.moveable-panel {
+  position: fixed;
+  background-color: #fff;
+  box-shadow: $boxShadow;
+  // border: 1px solid $borderColor;
+  border-radius: $borderRadius;
+  display: flex;
+  flex-direction: column;
+  z-index: 999;
+}
+.resizer {
+  width: 10px;
+  height: 10px;
+  position: absolute;
+  bottom: 0;
+  right: 0;
+  cursor: se-resize;
+
+  &::after {
+    content: "";
+    position: absolute;
+    bottom: -4px;
+    right: -4px;
+    transform: rotate(45deg);
+    transform-origin: center;
+    width: 0;
+    height: 0;
+    border: 6px solid transparent;
+    border-left-color: #e1e1e1;
+  }
+}
+.header {
+  height: 40px;
+  display: flex;
+  align-items: center;
+  border-bottom: 1px solid #f0f0f0;
+  cursor: move;
+}
+.title {
+  flex: 1;
+  font-size: 13px;
+  padding-left: 10px;
+}
+.close-btn {
+  width: 40px;
+  height: 40px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #666;
+  font-size: 13px;
+  cursor: pointer;
+}
+.content {
+  flex: 1;
+  padding: 10px;
+  overflow: auto;
+}
+</style>

+ 3 - 2
src/components/Popover.vue

@@ -96,9 +96,10 @@ onMounted(() => {
 .popover-content {
   background-color: #fff;
   padding: 10px;
-  border: 1px solid $borderColor;
+  // border: 1px solid $borderColor;
   box-shadow: $boxShadow;
-  border-radius: $borderRadius;
+  border-radius: 10px;
+  // border-radius: $borderRadius;
   font-size: 13px;
 }
 </style>

+ 6 - 4
src/components/PopoverMenuItem.vue

@@ -16,20 +16,22 @@ const emit = defineEmits<{
 }>()
 </script>
 
-<style lang="scss" scoped>
+<style lang="scss" sco1ped>
 .popover-menu-item {
   min-width: 80px;
-  padding: 6px 10px;
-  border-radius: $borderRadius;
+  padding: 10px 10px;
+  // border-radius: $borderRadius;
+  border-radius: 5px;
   font-size: 13px;
   cursor: pointer;
+  transition: all 0.3s ease;
 
   &.center {
     text-align: center;
   }
 
   &:hover {
-    background-color: #f1f1f1;
+    background-color: #f3f4f6;
   }
   & + .popover-menu-item {
     margin-top: 2px;

+ 40 - 11
src/components/WritingBoard.vue

@@ -6,18 +6,19 @@
       :style="{
         width: canvasWidth + 'px',
         height: canvasHeight + 'px',
+        pointerEvents: readonly ? 'none' : 'auto',
       }"
-      @mousedown="$event => handleMousedown($event)"
-      @mousemove="$event => handleMousemove($event)"
-      @mouseup="handleMouseup()"
-      @touchstart="$event => handleMousedown($event)"
-      @touchmove="$event => handleMousemove($event)"
-      @touchend="handleMouseup(); mouseInCanvas = false"
-      @mouseleave="handleMouseup(); mouseInCanvas = false"
-      @mouseenter="mouseInCanvas = true"
+      @mousedown="$event => !readonly && handleMousedown($event)"
+      @mousemove="$event => !readonly && handleMousemove($event)"
+      @mouseup="!readonly && handleMouseup()"
+      @touchstart="$event => !readonly && handleMousedown($event)"
+      @touchmove="$event => !readonly && handleMousemove($event)"
+      @touchend="!readonly && (handleMouseup(), mouseInCanvas = false)"
+      @mouseleave="!readonly && (handleMouseup(), mouseInCanvas = false)"
+      @mouseenter="!readonly && (mouseInCanvas = true)"
     ></canvas>
 
-    <template v-if="mouseInCanvas">
+    <template v-if="mouseInCanvas && !readonly">
       <div 
         class="eraser"
         :style="{
@@ -77,6 +78,7 @@ const props = withDefaults(defineProps<{
   markSize?: number
   rubberSize?: number
   shapeSize?: number
+  readonly?: boolean
 }>(), {
   color: '#ffcc00',
   model: 'pen',
@@ -86,6 +88,7 @@ const props = withDefaults(defineProps<{
   markSize: 24,
   rubberSize: 80,
   shapeSize: 4,
+  readonly: false,
 })
 
 const emit = defineEmits<{
@@ -343,7 +346,7 @@ const handleMove = (x: number, y: number) => {
     draw(x, y, props.markSize)
     lastPos = { x, y }
   }
-  else if (props.model ==='eraser') {
+  else if (props.model === 'eraser') {
     erase(x, y)
     lastPos = { x, y }
   }
@@ -425,9 +428,35 @@ const setImageDataURL = (imageDataURL: string) => {
     const img = new Image()
     img.src = imageDataURL
     img.onload = () => {
-      ctx!.drawImage(img, 0, 0)
+      // 直接按canvas尺寸绘制,不缩放(因为画图数据本身就是按canvas尺寸保存的)
+      // 如果canvas尺寸不同,需要按比例缩放
+      const canvasWidth = canvasRef.value!.width
+      const canvasHeight = canvasRef.value!.height
+      const imgWidth = img.width
+      const imgHeight = img.height
+      
+      // 如果图片尺寸和canvas尺寸一致,直接绘制
+      if (imgWidth === canvasWidth && imgHeight === canvasHeight) {
+        ctx!.drawImage(img, 0, 0)
+      }
+      else {
+        // 如果尺寸不一致,按比例缩放(保持宽高比)
+        const scaleX = canvasWidth / imgWidth
+        const scaleY = canvasHeight / imgHeight
+        const scale = Math.min(scaleX, scaleY)
+        
+        const scaledWidth = imgWidth * scale
+        const scaledHeight = imgHeight * scale
+        const x = (canvasWidth - scaledWidth) / 2
+        const y = (canvasHeight - scaledHeight) / 2
+        
+        ctx!.drawImage(img, x, y, scaledWidth, scaledHeight)
+      }
       updateCtx()
     }
+    img.onerror = () => {
+      console.warn('📝 画图图片加载失败:', imageDataURL.substring(0, 50) + '...')
+    }
   }
 }
 

+ 61 - 51
src/configs/chart.ts

@@ -1,57 +1,67 @@
 import type { ChartData } from '@/types/slides'
+import { lang } from '@/main'
 
-export const CHART_TYPE_MAP: { [key: string]: string } = {
-  'bar': '柱状图',
-  'column': '条形图',
-  'line': '折线图',
-  'area': '面积图',
-  'scatter': '散点图',
-  'pie': '饼图',
-  'ring': '环形图',
-  'radar': '雷达图',
-}
+export const getChartTypeMap = (): { [key: string]: string } => ({
+  bar: lang.ssChartColumn,
+  column: lang.ssChartBar,
+  line: lang.ssChartLine,
+  area: lang.ssChartArea,
+  scatter: lang.ssChartScatter,
+  pie: lang.ssChartPie,
+  ring: lang.ssChartRing,
+  radar: lang.ssChartRadar,
+})
+
+const makeSeq = (pattern: string, n: number) =>
+  Array.from({ length: n }, (_, i) => pattern.replace(/\*/g, String(i + 1)))
+
+export const getChartDefaultData = (): { [key: string]: ChartData } => {
+  const cat = makeSeq(lang.ssChartCat, 5)
+  const ser = makeSeq(lang.ssChartSer, 2)
+  const coord = makeSeq(lang.ssCoord, 5)
 
-export const CHART_DEFAULT_DATA: { [key: string]: ChartData } = {
-  'bar': {
-    labels: ['类别1', '类别2', '类别3', '类别4', '类别5'],
-    legends: ['系列1', '系列2'],
-    series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]],
-  },
-  'column': {
-    labels: ['类别1', '类别2', '类别3', '类别4', '类别5'],
-    legends: ['系列1', '系列2'],
-    series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]],
-  },
-  'line': {
-    labels: ['类别1', '类别2', '类别3', '类别4', '类别5'],
-    legends: ['系列1', '系列2'],
-    series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]],
-  },
-  'pie': {
-    labels: ['类别1', '类别2', '类别3', '类别4', '类别5'],
-    legends: ['值'],
-    series: [[12, 19, 5, 2, 18]],
-  },
-  'ring': {
-    labels: ['类别1', '类别2', '类别3', '类别4', '类别5'],
-    legends: ['值'],
-    series: [[12, 19, 5, 2, 18]],
-  },
-  'area': {
-    labels: ['类别1', '类别2', '类别3', '类别4', '类别5'],
-    legends: ['系列1', '系列2'],
-    series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]],
-  },
-  'radar': {
-    labels: ['类别1', '类别2', '类别3', '类别4', '类别5'],
-    legends: ['系列1', '系列2'],
-    series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]],
-  },
-  'scatter': {
-    labels: ['坐标1', '坐标2', '坐标3', '坐标4', '坐标5'],
-    legends: ['X', 'Y'],
-    series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]],
-  },
+  return {
+    bar: {
+      labels: cat,
+      legends: ser,
+      series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]],
+    },
+    column: {
+      labels: cat,
+      legends: ser,
+      series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]],
+    },
+    line: {
+      labels: cat,
+      legends: ser,
+      series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]],
+    },
+    pie: {
+      labels: cat,
+      legends: [lang.ssValue],
+      series: [[12, 19, 5, 2, 18]],
+    },
+    ring: {
+      labels: cat,
+      legends: [lang.ssValue],
+      series: [[12, 19, 5, 2, 18]],
+    },
+    area: {
+      labels: cat,
+      legends: ser,
+      series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]],
+    },
+    radar: {
+      labels: cat,
+      legends: ser,
+      series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]],
+    },
+    scatter: {
+      labels: coord,
+      legends: [lang.ssX, lang.ssY],
+      series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]],
+    },
+  }
 }
 
 export const CHART_PRESET_THEMES = [

+ 14 - 12
src/configs/element.ts

@@ -1,15 +1,17 @@
-export const ELEMENT_TYPE_ZH: { [key: string]: string } = {
-  text: '文本',
-  image: '图片',
-  shape: '形状',
-  line: '线条',
-  chart: '图表',
-  table: '表格',
-  video: '视频',
-  audio: '音频',
-  latex: '公式',
-  frame: '网页',
-}
+import { lang } from '@/i18n/lang'
+
+export const getElementTypeZh = (): { [key: string]: string } => ({
+  text: lang.ssElText,
+  image: lang.ssElImage,
+  shape: lang.ssElShape,
+  line: lang.ssElLine,
+  chart: lang.ssElChart,
+  table: lang.ssElTable,
+  video: lang.ssElVideo,
+  audio: lang.ssElAudio,
+  latex: lang.ssElLatex,
+  frame: lang.ssElFrame,
+})
 
 export const MIN_SIZE: { [key: string]: number } = {
   text: 40,

+ 24 - 22
src/configs/latex.ts

@@ -1,74 +1,76 @@
+import { lang } from '@/main'
+
 export const FORMULA_LIST = [
   {
-    label: '高斯公式',
+    label: lang.ssGauss,
     latex: `\\int\\int\\int _ { \\Omega } \\left( \\frac { \\partial {P} } { \\partial {x} } + \\frac { \\partial {Q} } { \\partial {y} } + \\frac { \\partial {R} }{ \\partial {z} } \\right) \\mathrm { d } V = \\oint _ { \\partial \\Omega } ( P \\cos \\alpha + Q \\cos \\beta + R \\cos \\gamma ) \\mathrm{ d} S`
   },
   {
-    label: '傅里叶级数',
+    label: lang.ssFourier,
     latex: `f(x) = \\frac {a_0} 2 + \\sum_{n = 1}^\\infty {({a_n}\\cos {nx} + {b_n}\\sin {nx})}`,
   },
   {
-    label: '泰勒展开式',
+    label: lang.ssTaylor,
     latex: `e ^ { x } = 1 + \\frac { x } { 1 ! } + \\frac { x ^ { 2 } } { 2 ! } + \\frac { x ^ { 3 } } { 3 ! } + ... , \\quad - \\infty < x < \\infty`,
   },
   {
-    label: '定积分',
+    label: lang.ssDefInt,
     latex: `\\lim_ { n \\rightarrow + \\infty } \\sum _ { i = 1 } ^ { n } f \\left[ a + \\frac { i } { n } ( b - a ) \\right] \\frac { b - a } { n } = \\int _ { a } ^ { b } f ( x ) dx`,
   },
   {
-    label: '三角恒等式1',
+    label: lang.ssTrigIdOne,
     latex: `\\sin \\alpha \\pm \\sin \\beta = 2 \\sin \\frac { 1 } { 2 } ( \\alpha \\pm \\beta ) \\cos \\frac { 1 } { 2 } ( \\alpha \\mp \\beta )`,
   },
   {
-    label: '三角恒等式2',
+    label: lang.ssTrigIdTwo,
     latex: `\\cos \\alpha + \\cos \\beta = 2 \\cos \\frac { 1 } { 2 } ( \\alpha + \\beta ) \\cos \\frac { 1 } { 2 } ( \\alpha - \\beta )`,
   },
   {
-    label: '和的展开式',
+    label: lang.ssSumExpand,
     latex: `( 1 + x ) ^ { n } = 1 + \\frac { n x } { 1 ! } + \\frac { n ( n - 1 ) x ^ { 2 } } { 2 ! } + ...`,
   },
   {
-    label: '欧拉公式',
+    label: lang.ssEuler,
     latex: ` e^{ix} = \\cos {x} + i\\sin {x}`,
   },
   {
-    label: '贝努利方程',
+    label: lang.ssBernoulli,
     latex: `\\frac {dy} {dx} + P(x)y = Q(x) y^n ({n} \\not= {0,1})`,
   },
   {
-    label: '全微分方程',
+    label: lang.ssExactDE,
     latex: `du(x,y) = P(x,y)dx + Q(x,y)dy = 0`,
   },
   {
-    label: '非齐次方程',
+    label: lang.ssNonHom,
     latex: `y = (\\int Q(x) e^{\\int {P(x)dx}}dx + C)e^{-\\int {P(x)dx}}`,
   },
   {
-    label: '柯西中值定理',
+    label: lang.ssCauchyMVT,
     latex: `\\frac{{f(b) - f(a)}}{{F(b) - F(a)}} = \\frac{{f'(\\xi )}}{{F'(\\xi )}}`,
   },
   {
-    label: '拉格朗日中值定理',
+    label: lang.ssLagMVT,
     latex: `f(b) - f(a) = f'(\\xi )(b - a)`,
   },
   {
-    label: '导数公式',
+    label: lang.ssDerivForm,
     latex: `(\\arcsin x)' = \\frac{1}{{\\sqrt {1 - x^2} }}`,
   },
   {
-    label: '三角函数积分',
+    label: lang.ssTrigInt,
     latex: `\\int {tgxdx = - \\ln \\left| {\\cos x} \\right| + C}`,
   },
   {
-    label: '二次曲面',
+    label: lang.ssQuadSurf,
     latex: `\\frac{{{x^2}}}{{{a^2}}} + \\frac{{{y^2}}}{{{b^2}}} - \\frac{{{z^2}}}{{{c^2}}} = 1`,
   },
   {
-    label: '二阶微分',
+    label: lang.ssSecDiff,
     latex: `\\frac {{d^2}y} {dx^2} + P(x) \\frac {dy} {dx} + Q(x)y = f(x)`,
   },
   {
-    label: '方向导数',
+    label: lang.ssDirDeriv,
     latex: `\\frac{{\\partial f}}{{\\partial l}} = \\frac{{\\partial f}}{{\\partial x}}\\cos \\phi + \\frac{{\\partial f}}{{\\partial y}}\\sin \\phi`,
   },
 ]
@@ -76,7 +78,7 @@ export const FORMULA_LIST = [
 export const SYMBOL_LIST = [
   {
     type: 'operators',
-    label: '数学',
+    label: lang.ssMath,
     children: [
       { latex: '\\cdot' },
       { latex: '\\pm' },
@@ -152,7 +154,7 @@ export const SYMBOL_LIST = [
   },
   {
     type: 'group',
-    label: '组合',
+    label: lang.ssCombo,
     children: [
       { latex: '\\frac{a}{b}' },
       { latex: '\\frac{dx}{dx}' },
@@ -186,7 +188,7 @@ export const SYMBOL_LIST = [
   },
   {
     type: 'verbatim',
-    label: '函数',
+    label: lang.ssFunc,
     children: [
       { latex: '\\log' },
       { latex: '\\ln' },
@@ -215,7 +217,7 @@ export const SYMBOL_LIST = [
   },
   {
     type: 'greek',
-    label: '希腊字母',
+    label: lang.ssGreek,
     children: [
       { latex: '\\alpha' },
       { latex: '\\beta' },

+ 3 - 3
src/configs/lines.ts

@@ -1,5 +1,5 @@
 import type { LinePoint, LineStyleType } from '@/types/slides'
-
+import { lang } from '@/main'
 
 export interface LinePoolItem {
   path: string
@@ -18,7 +18,7 @@ interface PresetLine {
 
 export const LINE_LIST: PresetLine[] = [
   {
-    type: '直线',
+    type: lang.ssLineStraight,
     children: [
       { path: 'M 0 0 L 20 20', style: 'solid', points: ['', ''] },
       { path: 'M 0 0 L 20 20', style: 'dashed', points: ['', ''] },
@@ -28,7 +28,7 @@ export const LINE_LIST: PresetLine[] = [
     ],
   },
   {
-    type: '折线、曲线',
+    type: lang.ssLinePolyCurve,
     children: [
       { path: 'M 0 0 L 0 20 L 20 20', style: 'solid', points: ['', 'arrow'], isBroken: true },
       { path: 'M 0 0 L 10 0 L 10 20 L 20 20', style: 'solid', points: ['', 'arrow'], isBroken2: true },

+ 6 - 5
src/configs/shapes.ts

@@ -3,6 +3,7 @@
 // 非专业设计人士可以用该应用绘制基本形状:https://github.com/pipipi-pikachu/svgPathCreator
 
 import { ShapePathFormulasKeys } from '@/types/slides'
+import { lang } from '@/i18n/lang'
 
 export interface ShapePoolItem {
   viewBox: [number, number]
@@ -247,7 +248,7 @@ export const SHAPE_PATH_FORMULAS: {
 
 export const SHAPE_LIST: ShapeListItem[] = [
   {
-    type: '矩形',
+    type: lang.ssShapeRect,
     children: [
       {
         viewBox: [200, 200],
@@ -306,7 +307,7 @@ export const SHAPE_LIST: ShapeListItem[] = [
   },
 
   {
-    type: '常用形状',
+    type: lang.ssShapeCommon,
     children: [
       {
         viewBox: [200, 200],
@@ -637,7 +638,7 @@ export const SHAPE_LIST: ShapeListItem[] = [
   },
   
   {
-    type: '箭头',
+    type: lang.ssShapeArrow,
     children: [
       {
         viewBox: [200, 200],
@@ -755,7 +756,7 @@ export const SHAPE_LIST: ShapeListItem[] = [
   },
 
   {
-    type: '其他形状',
+    type: lang.ssShapeOther,
     children: [
       {
         viewBox: [1024, 1024],
@@ -941,7 +942,7 @@ export const SHAPE_LIST: ShapeListItem[] = [
   },
 
   {
-    type: '线性',
+    type: lang.ssShapeLinear,
     children: [
       {
         viewBox: [1024, 1024],

+ 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 }, httpOptions: { timeout: number } }) => 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;
 }

+ 4 - 2
src/hooks/useCreateElement.ts

@@ -3,10 +3,11 @@ import { nanoid } from 'nanoid'
 import { useMainStore, useSlidesStore } from '@/store'
 import { getImageSize } from '@/utils/image'
 import message from '@/utils/message'
+import { lang } from '@/main'
 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 { getChartDefaultData } from '@/configs/chart'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
 
 interface CommonElementPosition {
@@ -88,6 +89,7 @@ export default () => {
    * @param chartType 图表类型
    */
   const createChartElement = (type: ChartType) => {
+    const CHART_DEFAULT_DATA = getChartDefaultData()
     createElement({
       type: 'chart',
       id: nanoid(10),
@@ -322,7 +324,7 @@ export default () => {
     const hasWebpage = currentSlide.value?.elements?.some(element => element.type === 'frame')
     
     if (hasWebpage) {
-      message.error('当前幻灯片已有学习内容,一个幻灯片只能插入一个学习内容')
+      message.error(lang.ssSlideLearn)
       return
     }
     

Разница между файлами не показана из-за своего большого размера
+ 954 - 50
src/hooks/useImport.ts


+ 3 - 2
src/hooks/useLink.ts

@@ -2,6 +2,7 @@ import { useSlidesStore } from '@/store'
 import type { PPTElement, PPTElementLink } from '@/types/slides'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
 import message from '@/utils/message'
+import { lang } from '@/main'
 
 export default () => {
   const slidesStore = useSlidesStore()
@@ -11,11 +12,11 @@ export default () => {
   const setLink = (handleElement: PPTElement, link: PPTElementLink) => {
     const linkRegExp = /^(https?):\/\/[\w\-]+(\.[\w\-]+)+([\w\-.,@?^=%&:\/~+#]*[\w\-@?^=%&\/~+#])?$/
     if (link.type === 'web' && !linkRegExp.test(link.target)) {
-      message.error('不是正确的网页链接地址')
+      message.error(lang.ssWebUrlInvalid)
       return false
     }
     if (link.type === 'slide' && !link.target) {
-      message.error('请先选择链接目标')
+      message.error(lang.ssPickLinkTarget)
       return false
     }
     const props = { link }

+ 4 - 3
src/hooks/useSearch.ts

@@ -3,6 +3,7 @@ import { storeToRefs } from 'pinia'
 import { useMainStore, useSlidesStore } from '@/store'
 import type { PPTTableElement } from '@/types/slides'
 import message from '@/utils/message'
+import { lang } from '@/main'
 
 interface SearchTextResult {
   elType: 'text' | 'shape'
@@ -83,7 +84,7 @@ export default () => {
       highlightCurrentSlide()
     }
     else {
-      message.warning('未查找到匹配项')
+      message.warning(lang.ssNoMatch)
       clearMarks()
     }
   }
@@ -229,7 +230,7 @@ export default () => {
   }
   
   const searchNext = () => {
-    if (!searchWord.value) return message.warning('请先输入查找内容')
+    if (!searchWord.value) return message.warning(lang.ssFindInput)
     mainStore.setActiveElementIdList([])
     if (searchIndex.value === -1) search()
     else if (searchIndex.value < searchResults.value.length - 1) searchIndex.value += 1
@@ -238,7 +239,7 @@ export default () => {
   }
   
   const searchPrev = () => {
-    if (!searchWord.value) return message.warning('请先输入查找内容')
+    if (!searchWord.value) return message.warning(lang.ssFindInput)
     mainStore.setActiveElementIdList([])
     if (searchIndex.value === -1) search()
     else if (searchIndex.value > 0) searchIndex.value -= 1

+ 27 - 1
src/hooks/useSlideHandler.ts

@@ -16,7 +16,7 @@ export default () => {
   const mainStore = useMainStore()
   const slidesStore = useSlidesStore()
   const { selectedSlidesIndex: _selectedSlidesIndex, activeElementIdList } = storeToRefs(mainStore)
-  const { currentSlide, slides, theme, slideIndex } = storeToRefs(slidesStore)
+  const { currentSlide, slides, theme, slideIndex, viewportSize, viewportRatio } = storeToRefs(slidesStore)
 
   const selectedSlidesIndex = computed(() => [..._selectedSlidesIndex.value, slideIndex.value])
   const selectedSlides = computed(() => slides.value.filter((item, index) => selectedSlidesIndex.value.includes(index)))
@@ -93,9 +93,35 @@ export default () => {
   // 根据模板创建新页面
   const createSlideByTemplate = (slide: Slide) => {
     const { groupIdMap, elIdMap } = createElementIdMap(slide.elements)
+    const slideWidth = viewportSize.value
+    const slideHeight = viewportSize.value * viewportRatio.value
+    
+    // 模板原始宽高(16:9比例)
+    const templateWidth = 1280
+    const templateHeight = 720
+    
+    // 计算缩放因子
+    const scaleX = slideWidth / templateWidth
+    const scaleY = slideHeight / templateHeight
 
     for (const element of slide.elements) {
       element.id = elIdMap[element.id]
+      element.top = (element.top || 0) * scaleY
+      element.left = (element.left || 0) * scaleX
+      element.width = (element.width || 0) * scaleX
+      if (element.type === 'text' || element.type === 'shape') {
+        element.height = (element.height || 0) * scaleY
+      }
+      
+      // 处理 content 中的字体大小
+      if (element.type === 'text') {
+        // 匹配 style 中的 font-size 属性
+        element.content = element.content.replace(/font-size:\s*(\d+)px/g, (match, fontSize) => {
+          const newFontSize = Math.round(parseInt(fontSize) * scaleY)
+          return `font-size: ${newFontSize}px`
+        })
+      }
+      
       if (element.groupId) element.groupId = groupIdMap[element.groupId]
     }
     const newSlide = {

+ 9 - 0
src/i18n/lang.ts

@@ -0,0 +1,9 @@
+import en from '@/views/lang/en.json'
+import cn from '@/views/lang/cn.json'
+import hk from '@/views/lang/hk.json'
+
+const href = window.location.href.toLowerCase()
+export const lang =
+  href.includes('cocorobo.com') ? en
+    : href.includes('cocorobo.hk') ? hk
+      : cn

+ 27 - 0
src/main.ts

@@ -1,6 +1,16 @@
 import { createApp } from 'vue'
 import { createPinia } from 'pinia'
 import App from './App.vue'
+import { lang } from '@/i18n/lang'
+
+export { lang }
+
+// TypeScript declarations for global properties
+declare module '@vue/runtime-core' {
+  interface ComponentCustomProperties {
+    $version: 'cn' | 'hk' | 'com'
+  }
+}
 
 import '@icon-park/vue-next/styles/index.css'
 import 'prosemirror-view/style/prosemirror.css'
@@ -12,7 +22,24 @@ import '@/assets/styles/font.scss'
 import Icon from '@/plugins/icon'
 import Directive from '@/plugins/directive'
 
+// 全局变量:判断当前版本 (cn/hk/com)
+export const getCurrentVersion = () => {
+  const href = window.location.href.toLowerCase()
+  if (href.includes('cn')) return 'cn'
+  if (href.includes('hk')) return 'hk'
+  if (href.includes('com')) return 'com'
+  return 'cn' // 默认cn版本
+}
+
+// 当前版本
+export const currentVersion = getCurrentVersion()
+
+export default {currentVersion, lang}
+
 const app = createApp(App)
+// 注册全局变量
+app.config.globalProperties.$version = currentVersion
+
 app.use(Icon)
 app.use(Directive)
 app.use(createPinia())

+ 1 - 1
src/plugins/directive/tooltip.scss

@@ -4,7 +4,7 @@
   border-radius: $borderRadius;
   padding: 8px;
   font-size: 12px;
-  line-height: 1.5;
+  line-height: 1.15;
 
   .tippy-arrow {
     width: 12px;

+ 6 - 0
src/plugins/icon.ts

@@ -98,6 +98,7 @@ import {
   Power,
   ListView,
   Magic,
+  Tips,
   HighLight,
   Download,
   IndentLeft,
@@ -127,6 +128,8 @@ import {
   User,
   Switch,
   More,
+  LoadingFour, // 引入loadingIcon
+  UpTwo
 } from '@icon-park/vue-next'
 
 export interface Icons {
@@ -230,6 +233,7 @@ export const icons: Icons = {
   IconPower: Power,
   IconListView: ListView,
   IconMagic: Magic,
+  IconTips: Tips,
   IconHighLight: HighLight,
   IconDownload: Download,
   IconIndentLeft: IndentLeft,
@@ -259,6 +263,8 @@ export const icons: Icons = {
   IconUser: User,
   IconSwitch: Switch,
   IconMore: More,
+  IconLoading: LoadingFour, // 添加loadingIcon
+  UpTwo: UpTwo
 }
 
 export default {

+ 52 - 5
src/services/config.ts

@@ -3,7 +3,8 @@ import message from '@/utils/message'
 import qs from 'qs'
 
 const instance = axios.create({ timeout: 1000 * 300 })
-
+const cancelToken = axios.CancelToken
+axios.defaults.withCredentials = true
 // POST传参序列化(添加请求拦截器)
 
 instance.interceptors.request.use(
@@ -13,6 +14,13 @@ instance.interceptors.request.use(
     // 修复 config.data 可能为 undefined 的问题
     const data = config.data ?? {}
 
+    // 确保每个请求都带上cookie
+    config.withCredentials = true
+
+    if (url.includes('https://gpt4.cocorobo.cn') || url.includes('https://appapi.cocorobo.cn') || url.includes('https://ccrb.s3.cn-northwest-1.amazonaws.com.cn')) {
+      config.withCredentials = false 
+    }
+
     // 需要 form-urlencoded 且 data 为数组的情况
     if (
       config.method === 'post' &&
@@ -59,6 +67,8 @@ instance.interceptors.request.use(
       config.data = encoded
     }
 
+
+
     return config
   },
   (error) => {
@@ -72,11 +82,41 @@ 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) => {
+    // 处理请求取消的情况
+    if (axios.isCancel(error)) {
+      console.log('请求被取消:', error.message)
+      return Promise.reject(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)
@@ -84,14 +124,21 @@ instance.interceptors.response.use(
       else if (error.response.status >= 500) {
         return Promise.reject(error.message)
       }
-
+      
       message.error('服务器遇到未知错误!')
       return Promise.reject(error.message)
     }
-
-    message.error('连接到服务器失败 或 服务器响应超时!')
+    if (fullUrl !== 'https://r2rserver.cocorobo.cn/v3/documents') {
+      message.error(fullUrl)
+      message.error(error)
+    }
+    else {
+      console.error(fullUrl)
+      console.error(error)
+    }
     return Promise.reject(error)
   }
 )
 
 export default instance
+export { cancelToken }

+ 31 - 2
src/services/course.ts

@@ -2,6 +2,7 @@ import axios from './config'
 
 export const API_URL = 'https://pbl.cocorobo.cn/api/pbl/'
 export const yweb_socket = 'wss://yjs.cocorobo.cn'
+// export const yweb_socket = 'wss://yrs.cocorobo.cn'
 
 /**
  * 获取课程详情
@@ -14,6 +15,17 @@ export const getCourseDetail = (courseId: string): Promise<any> => {
   })
 }
 
+/**
+ * 获取课程详情
+ * @param courseId 课程ID
+ * @returns Promise<any>
+ */
+export const getPPTFile = (courseId: string, classid: string): Promise<any> => {
+  return axios.get(`${API_URL}getPPTFile`, {
+    params: { pptid: courseId, classid },
+  })
+}
+
 /**
  * 提交作业接口
  * @param params 参数对象
@@ -71,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 })
 }
 
 /**
@@ -170,7 +182,21 @@ export const clearDialogue = (params: any): Promise<any> => {
   return axios.delete(`https://appapi.cocorobo.cn/api/agentchats/clear_thread_context`, { data: params })
 }
 
+/**
+ * 
+ * 获取作业·详细
+ * @param any 作业id
+ * @returns Promise<any>
+ */
 
+export const getWorkDetail = (params: any): Promise<any> => {
+  return axios.get(`${API_URL}select_workPageById`, { params: params })
+}
+
+
+export const getWorkPageId = (params: any): Promise<any> => {
+  return axios.post(`${API_URL}insert_workPage`, [params])
+}
 
 
 
@@ -178,6 +204,7 @@ export const clearDialogue = (params: any): Promise<any> => {
 
 export default {
   getCourseDetail,
+  getPPTFile,
   submitWork,
   selectSWorks,
   selectWorksStudent,
@@ -191,5 +218,7 @@ export default {
   getChatList,
   getAgentData,
   clearDialogue,
+  getWorkDetail,
+  getWorkPageId
 }
 

+ 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

+ 3 - 1
src/store/slides.ts

@@ -50,7 +50,8 @@ export const useSlidesStore = defineStore('slides', {
     }, // 主题样式
     slides: [], // 幻灯片页面数据
     slideIndex: 0, // 当前页面索引
-    viewportSize: 1000, // 可视区域宽度基数
+    // viewportSize: 1000, // 可视区域宽度基数
+    viewportSize: 1280, // 可视区域宽度基数
     viewportRatio: 0.5625, // 可视区域比例,默认16:9
     templates: [
       { name: '红色通用', id: 'template_1', cover: 'https://ccrb.s3.cn-northwest-1.amazonaws.com.cn/Snipaste_2025-08-15_14-10-111755238224052.png' },
@@ -206,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
 

+ 244 - 0
src/tools/aiChat.ts

@@ -0,0 +1,244 @@
+import axios, { cancelToken } from '@/services/config'
+import { v4 as uuidv4 } from 'uuid'
+import { fetchEventSource } from '@microsoft/fetch-event-source'
+
+const model = {}
+
+interface ChatParams {
+  id: string;
+  message: string;
+  userId: string;
+  model: string;
+  file_ids: string[];
+  sound_url: string;
+  temperature: number;
+  top_p: number;
+  max_completion_tokens: number;
+  stream: boolean;
+  uid: string;
+  session_name: string;
+  tts_language: string;
+}
+
+const DEFAULT_PARAMS: Omit<ChatParams, 'message' | 'uid' | 'stream'> = {
+  id: 'a7741704-ba56-40b7-a6b8-62a423ef9376',
+  userId: '6c56ec0e-2c74-11ef-bee5-005056b86db5',
+  model: 'open-doubao',
+  file_ids: [],
+  sound_url: '',
+  temperature: 0.2,
+  top_p: 1,
+  max_completion_tokens: 4096,
+  session_name: 'pptSession_name',
+  tts_language: 'zh-CN'
+}
+
+export const chat_no_stream = (msg: string, agentId: string, userId: string, language: string, session_name?: string): { promise: Promise<string>; abort: () => void } => {
+  const source = cancelToken.source()
+  
+  const promise = (async () => {
+    const agentData = await getAgentModel(agentId)
+    const params: ChatParams = {
+      ...DEFAULT_PARAMS,
+      id: agentId,
+      message: msg,
+      uid: uuidv4(),
+      stream: false,
+      model: agentData?.modelType || 'open-doubao',
+      userId: userId,
+      tts_language: getTtsLanguage(language),
+      session_name: session_name || uuidv4()
+    }
+
+    try {
+      const res = await axios.post('https://appapi.cocorobo.cn/api/agentchats/ai_agent_chat', params, {
+        cancelToken: source.token
+      })
+      let content = res?.message || ''
+      console.log(content)
+      
+      // 清理可能的 markdown 格式
+      if (content.includes('```json')) {
+        // 提取 ```json 和 ``` 之间的内容
+        const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/)
+        if (jsonMatch) {
+          content = jsonMatch[1].trim()
+        }
+      }
+      else if (content.includes('```')) {
+        // 提取 ``` 和 ``` 之间的内容
+        const codeMatch = content.match(/```\s*([\s\S]*?)\s*```/)
+        if (codeMatch) {
+          content = codeMatch[1].trim()
+        }
+      }
+
+      return content
+    }
+    catch (error) {
+      console.log(error)
+      return ''
+    }
+  })()
+
+  return {
+    promise,
+    abort: () => source.cancel('Request canceled by user')
+  }
+}
+
+export const chat_stream = async (
+  msg: string,
+  agentId: string,
+  userId: string,
+  language: string,
+  onMessage: (event: { type: 'message' | 'close' | 'error' | 'messageEnd'; data: string }) => void,
+  session_name?: string,
+  file_ids?: Array<string>
+): Promise<{ abort: () => void }> => {
+  const agentData = await getAgentModel(agentId)
+  const params: ChatParams = {
+    ...DEFAULT_PARAMS,
+    id: agentId,
+    file_ids: file_ids || [],
+    message: msg,
+    uid: uuidv4(),
+    stream: true,
+    model: agentData?.modelType || 'open-doubao',
+    userId: userId,
+    tts_language: getTtsLanguage(language),
+    session_name: session_name || uuidv4()
+  }
+
+  const ctrl = new AbortController()
+  let content = ''
+  
+  // 开始请求
+  fetchEventSource('https://appapi.cocorobo.cn/api/agentchats/ai_agent_chat', {
+    method: 'POST',
+    body: JSON.stringify(params),
+    signal: ctrl.signal,
+    headers: {
+      'Content-Type': 'application/json',
+    },
+    onmessage(event) {
+      const data = JSON.parse(event.data)
+      if (data.content) {
+        if (data.content != '[DONE]') {
+          content += data.content
+          onMessage({
+            type: 'message',
+            data: content
+          })
+        }
+        else {
+          onMessage({
+            type: 'messageEnd',
+            data: content
+          })
+        }
+
+      }
+    },
+    onclose() {
+      onMessage({
+        type: 'close',
+        data: 'SSE Connection closed'
+      })
+    },
+    onerror(err) {
+      onMessage({
+        type: 'error',
+        data: err.message || 'Unknown error'
+      })
+      // 返回 undefined 以阻止自动重连,如需重连则删除此行
+      throw err
+    },
+  }).catch(err => {
+    console.log('err', err)
+  })
+  
+  // 返回 abort 方法
+  return {
+    abort: () => ctrl.abort()
+  }
+
+}
+
+export const getAgentModel = async (agentId: string) => {
+  if (model[agentId]) {
+    return model[agentId]
+  }
+  const res = await axios.get(`https://appapi.cocorobo.cn/api/agents/agent/${agentId}`)
+  model[agentId] = res
+  return model[agentId]
+}
+
+export const getTtsLanguage = (langCode: string) => {
+  switch (langCode) {
+    case 'en':return 'en-US'
+    case 'hk':return 'yue-CN'
+    default :return 'zh-CN'
+  }
+}
+
+// AI 模型常量
+const AI_MODEL_CONSTANTS = {
+  DEFAULT_MODEL: 'gpt-4o-2024-11-20'
+}
+
+export const chat_no_stream2 = async (prompt: any[] = [], response_format = {
+  'type': 'text'
+}, model = AI_MODEL_CONSTANTS.DEFAULT_MODEL) => {
+  return await new Promise((resolve) => {
+    const uid = uuidv4()
+    const data = JSON.stringify({
+      model: model,
+      temperature: 0,
+      max_tokens: 4096,
+      top_p: 1,
+      frequency_penalty: 0,
+      presence_penalty: 0,
+      messages: prompt,
+      uid: uid,
+      mind_map_question: '',
+      stream: false,
+      response_format: response_format
+    })
+    const config = {
+      method: 'post',
+      url: 'https://appapi.cocorobo.cn/api/common/chat',
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      data: data,
+    }
+    axios(config)
+      .then((response) => {
+        let content = response?.FunctionResponse?.choices[0]?.message?.content || ''
+
+        // 清理可能的 markdown 格式
+        if (content.includes('```json')) {
+          // 提取 ```json 和 ``` 之间的内容
+          const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/)
+          if (jsonMatch) {
+            content = jsonMatch[1].trim()
+          }
+        }
+        else if (content.includes('```')) {
+          // 提取 ``` 和 ``` 之间的内容
+          const codeMatch = content.match(/```\s*([\s\S]*?)\s*```/)
+          if (codeMatch) {
+            content = codeMatch[1].trim()
+          }
+        }
+
+        resolve(content)
+      })
+      .catch( (error) => {
+        console.log(error)
+        // this.$message.error('服务器繁忙');
+        resolve(false)
+      })
+  })
+}

+ 5 - 1
src/types/slides.ts

@@ -185,6 +185,8 @@ export interface PPTTextElement extends PPTBaseElement {
   paragraphSpace?: number
   vertical?: boolean
   textType?: TextType
+  style?: string,
+  align?: string
 }
 
 
@@ -311,6 +313,7 @@ export interface ShapeText {
   defaultColor: string
   align: ShapeTextAlign
   type?: TextType
+  style?: string
 }
 
 /**
@@ -366,7 +369,8 @@ export interface PPTShapeElement extends PPTBaseElement {
   special?: boolean
   text?: ShapeText
   pathFormula?: ShapePathFormulasKeys
-  keypoints?: number[]
+  keypoints?: number[],
+  pathBBox?: object
 }
 
 

+ 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/utils/prosemirror/schema/nodes.ts

@@ -151,7 +151,7 @@ const paragraph: NodeSpec = {
   toDOM: (node: Node) => {
     const { align, indent, textIndent } = node.attrs
     let style = ''
-    if (align && align !== 'left') style += `text-align: ${align};`
+    if (align && align !== 'left') style += `text-align: ${align};text-align-last: ${align};`
     if (textIndent) style += `text-indent: ${textIndent}em;`
 
     const attr: Attr = { style }

+ 41 - 40
src/views/Editor/AIPPTDialog.vue

@@ -2,9 +2,9 @@
   <div class="aippt-dialog">
     <div class="header">
       <span class="title">AIPPT</span>
-      <span class="subtite" v-if="step === 'template'">从下方挑选合适的模板,开始生成PPT</span>
-      <span class="subtite" v-else-if="step === 'outline'">确认下方内容大纲(点击编辑内容,右键添加/删除大纲项),开始选择模板</span>
-      <span class="subtite" v-else>在下方输入您的PPT主题,并适当补充信息,如行业、岗位、学科、用途等</span>
+      <span class="subtite" v-if="step === 'template'">{{ lang.ssAiSubTpl }}</span>
+      <span class="subtite" v-else-if="step === 'outline'">{{ lang.ssAiSubOut }}</span>
+      <span class="subtite" v-else>{{ lang.ssAiSubSet }}</span>
     </div>
     
     <template v-if="step === 'setup'">
@@ -12,12 +12,12 @@
         ref="inputRef"
         v-model:value="keyword" 
         :maxlength="50" 
-        placeholder="请输入PPT主题,如:大学生职业生涯规划" 
+        :placeholder="lang.ssAiPhTopic" 
         @enter="createOutline()"
       >
         <template #suffix>
           <span class="count">{{ keyword.length }} / 50</span>
-          <div class="submit" type="primary" @click="createOutline()"><IconSend class="icon" /> AI 生成</div>
+          <div class="submit" type="primary" @click="createOutline()"><IconSend class="icon" /> {{ lang.ssAiGen }}</div>
         </template>
       </Input>
       <div class="recommends">
@@ -25,35 +25,35 @@
       </div>
       <div class="configs">
         <div class="config-item">
-          <div class="label">语言:</div>
+          <div class="label">{{ lang.ssLangColon }}</div>
           <Select 
             class="config-content"
             style="width: 80px;"
             v-model:value="language"
             :options="[
-              { label: '中文', value: '中文' },
-              { label: '英文', value: 'English' },
-              { label: '日文', value: '日本語' },
+              { label: lang.ssLangZh, value: lang.ssLangZhV },
+              { label: lang.ssLangEn, value: 'English' },
+              { label: lang.ssLangJa, value: '日本語' },
             ]"
           />
         </div>
         <div class="config-item">
-          <div class="label">风格:</div>
+          <div class="label">{{ lang.ssStyleColon }}</div>
           <Select 
             class="config-content"
             style="width: 80px;"
             v-model:value="style"
             :options="[
-              { label: '通用', value: '通用' },
-              { label: '学术风', value: '学术风' },
-              { label: '职场风', value: '职场风' },
-              { label: '教育风', value: '教育风' },
-              { label: '营销风', value: '营销风' },
+              { label: lang.ssStyGen, value: lang.ssStyGenV },
+              { label: lang.ssStyAcad, value: lang.ssStyAcadV },
+              { label: lang.ssStyWork, value: lang.ssStyWorkV },
+              { label: lang.ssStyEdu, value: lang.ssStyEduV },
+              { label: lang.ssStyMkt, value: lang.ssStyMktV },
             ]"
           />
         </div>
         <div class="config-item">
-          <div class="label">模型:</div>
+          <div class="label">{{ lang.ssModelColon }}</div>
           <Select 
             class="config-content"
             style="width: 190px;"
@@ -67,16 +67,16 @@
           />
         </div>
         <div class="config-item">
-          <div class="label">配图:</div>
+          <div class="label">{{ lang.ssImgColon }}</div>
           <Select 
             class="config-content"
             style="width: 100px;"
             v-model:value="img"
             :options="[
-              { label: '无', value: '' },
-              { label: '模拟测试', value: 'test' },
-              { label: 'AI搜图', value: 'ai-search', disabled: true },
-              { label: 'AI生图', value: 'ai-create', disabled: true },
+              { label: lang.ssNone, value: '' },
+              { label: lang.ssMockTest, value: 'test' },
+              { label: lang.ssAiSearch, value: 'ai-search', disabled: true },
+              { label: lang.ssAiImgGen, value: 'ai-create', disabled: true },
             ]"
           />
         </div>
@@ -88,8 +88,8 @@
          <OutlineEditor v-model:value="outline" />
        </div>
       <div class="btns" v-if="!outlineCreating">
-        <Button class="btn" type="primary" @click="step = 'template'">选择模板</Button>
-        <Button class="btn" @click="outline = ''; step = 'setup'">返回重新生成</Button>
+        <Button class="btn" type="primary" @click="step = 'template'">{{ lang.ssAiChooseTpl }}</Button>
+        <Button class="btn" @click="outline = ''; step = 'setup'">{{ lang.ssAiBackRe }}</Button>
       </div>
     </div>
     <div class="select-template" v-if="step === 'template'">
@@ -104,12 +104,12 @@
         </div>
       </div>
       <div class="btns">
-        <Button class="btn" type="primary" @click="createPPT()">生成</Button>
-        <Button class="btn" @click="step = 'outline'">返回大纲</Button>
+        <Button class="btn" type="primary" @click="createPPT()">{{ lang.ssAiMake }}</Button>
+        <Button class="btn" @click="step = 'outline'">{{ lang.ssAiBackOut }}</Button>
       </div>
     </div>
 
-    <FullscreenSpin :loading="loading" tip="AI生成中,请耐心等待 ..." />
+    <FullscreenSpin :loading="loading" :tip="lang.ssAiWait" />
   </div>
 </template>
 
@@ -127,14 +127,15 @@ import Button from '@/components/Button.vue'
 import Select from '@/components/Select.vue'
 import FullscreenSpin from '@/components/FullscreenSpin.vue'
 import OutlineEditor from '@/components/OutlineEditor.vue'
+import { lang } from '@/main'
 
 const mainStore = useMainStore()
 const slideStore = useSlidesStore()
 const { templates } = storeToRefs(slideStore)
 const { AIPPT, presetImgPool, getMdContent } = useAIPPT()
 
-const language = ref('中文')
-const style = ref('通用')
+const language = ref(lang.ssLangZhV)
+const style = ref(lang.ssStyGenV)
 const img = ref('')
 const keyword = ref('')
 const outline = ref('')
@@ -147,17 +148,17 @@ const outlineRef = useTemplateRef<HTMLElement>('outlineRef')
 const inputRef = useTemplateRef<InstanceType<typeof Input>>('inputRef')
 
 const recommends = ref([
-  '2025科技前沿动态',
-  '大数据如何改变世界',
-  '餐饮市场调查与研究',
-  'AIGC在教育领域的应用',
-  '社交媒体与品牌营销',
-  '5G技术如何改变我们的生活',
-  '年度工作总结与展望',
-  '区块链技术及其应用',
-  '大学生职业生涯规划',
-  '公司年会策划方案',
-]) 
+  lang.ssAiRecA,
+  lang.ssAiRecB,
+  lang.ssAiRecC,
+  lang.ssAiRecD,
+  lang.ssAiRecE,
+  lang.ssAiRecF,
+  lang.ssAiRecG,
+  lang.ssAiRecH,
+  lang.ssAiRecI,
+  lang.ssAiRecJ,
+])
 
 onMounted(() => {
   setTimeout(() => {
@@ -171,7 +172,7 @@ const setKeyword = (value: string) => {
 }
 
 const createOutline = async () => {
-  if (!keyword.value) return message.error('请先输入PPT主题')
+  if (!keyword.value) return message.error(lang.ssAiNeedTopic)
 
   loading.value = true
   outlineCreating.value = true

+ 29 - 37
src/views/Editor/Canvas/EditableElement.vue

@@ -32,6 +32,7 @@ import useSelectElement from '@/hooks/useSelectElement'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
 import { useSlidesStore } from '@/store'
 import message from '@/utils/message'
+import { lang } from '@/main'
 
 import { ElementOrderCommands, ElementAlignCommands } from '@/types/edit'
 
@@ -82,90 +83,90 @@ const { selectAllElements } = useSelectElement()
 const contextmenus = (): ContextmenuItem[] => {
   if (props.elementInfo.lock) {
     return [{
-      text: '解锁', 
+      text: lang.ssUnlock, 
       handler: () => unlockElement(props.elementInfo),
     }]
   }
 
   const baseMenu = [
     {
-      text: '剪切',
+      text: lang.ssCut,
       subText: 'Ctrl + X',
       handler: cutElement,
     },
     {
-      text: '复制',
+      text: lang.ssCopy,
       subText: 'Ctrl + C',
       handler: copyElement,
     },
     {
-      text: '粘贴',
+      text: lang.ssPaste,
       subText: 'Ctrl + V',
       handler: pasteElement,
     },
     { divider: true },
     {
-      text: '水平居中',
+      text: lang.ssAlignHCenter,
       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: lang.ssAlignHVCenter, handler: () => alignElementToCanvas(ElementAlignCommands.CENTER), },
+        { text: lang.ssAlignHCenter, handler: () => alignElementToCanvas(ElementAlignCommands.HORIZONTAL) },
+        { text: lang.ssAlignLeft, handler: () => alignElementToCanvas(ElementAlignCommands.LEFT) },
+        { text: lang.ssAlignRight, handler: () => alignElementToCanvas(ElementAlignCommands.RIGHT) },
       ],
     },
     {
-      text: '垂直居中',
+      text: lang.ssAlignVCenter,
       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: lang.ssAlignHVCenter, handler: () => alignElementToCanvas(ElementAlignCommands.CENTER) },
+        { text: lang.ssAlignVCenter, handler: () => alignElementToCanvas(ElementAlignCommands.VERTICAL) },
+        { text: lang.ssAlignTop, handler: () => alignElementToCanvas(ElementAlignCommands.TOP) },
+        { text: lang.ssAlignBottom, handler: () => alignElementToCanvas(ElementAlignCommands.BOTTOM) },
       ],
     },
     { divider: true },
     {
-      text: '置于顶层',
+      text: lang.ssBringFront,
       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: lang.ssBringFront, handler: () => orderElement(props.elementInfo, ElementOrderCommands.TOP) },
+        { text: lang.ssBringForward, handler: () => orderElement(props.elementInfo, ElementOrderCommands.UP) },
       ],
     },
     {
-      text: '置于底层',
+      text: lang.ssSendBack,
       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: lang.ssSendBack, handler: () => orderElement(props.elementInfo, ElementOrderCommands.BOTTOM) },
+        { text: lang.ssSendBackward, handler: () => orderElement(props.elementInfo, ElementOrderCommands.DOWN) },
       ],
     },
     { divider: true },
     {
-      text: '设置链接',
+      text: lang.ssSetLink,
       handler: props.openLinkDialog,
     },
     {
-      text: props.elementInfo.groupId ? '取消组合' : '组合',
+      text: props.elementInfo.groupId ? lang.ssUngroupEl : lang.ssGroupEl,
       subText: 'Ctrl + G',
       handler: props.elementInfo.groupId ? uncombineElements : combineElements,
       hide: !props.isMultiSelect,
     },
     {
-      text: '全选',
+      text: lang.ssSelectAll,
       subText: 'Ctrl + A',
       handler: selectAllElements,
     },
     {
-      text: '锁定',
+      text: lang.ssLock,
       subText: 'Ctrl + L',
       handler: lockElement,
     },
     {
-      text: '删除',
+      text: lang.ssDelete,
       subText: 'Delete',
       handler: deleteElement,
     },
@@ -174,17 +175,8 @@ const contextmenus = (): ContextmenuItem[] => {
   // 为网页元素添加特殊菜单项
   if (props.elementInfo.type === ElementTypes.FRAME) {
     const frameMenu = [
-      // {
-      //   text: '修改链接',
-      //   handler: () => {
-      //     const frameElement = props.elementInfo as any
-      //     if (frameElement.url) {
-      //       props.openWebpageLinkEditDialog(frameElement.id, frameElement.url)
-      //     }
-      //   },
-      // },
       {
-        text: '在新窗口打开',
+        text: lang.ssOpenNewWin,
         handler: () => {
           const frameElement = props.elementInfo as any
           if (frameElement.url) {
@@ -193,7 +185,7 @@ const contextmenus = (): ContextmenuItem[] => {
         },
       },
       {
-        text: '复制链接',
+        text: lang.ssCopyLink,
         handler: () => {
           const frameElement = props.elementInfo as any
           if (frameElement.url) {
@@ -204,7 +196,7 @@ const contextmenus = (): ContextmenuItem[] => {
       { divider: true },
     ]
     // 为网页元素过滤掉"设置链接"功能
-    const filteredBaseMenu = baseMenu.filter(item => item.text !== '设置链接')
+    const filteredBaseMenu = baseMenu.filter(item => item.text !== lang.ssSetLink)
     return [...frameMenu, ...filteredBaseMenu]
   }
 

+ 8 - 7
src/views/Editor/Canvas/LinkDialog.vue

@@ -11,7 +11,7 @@
       ref="inputRef"
       v-if="type === 'web'" 
       v-model:value="address" 
-      placeholder="请输入网页链接地址"
+      :placeholder="lang.ssWebUrlPh"
       @enter="save()"
     />
 
@@ -23,13 +23,13 @@
     />
 
     <div class="preview" v-if="type === 'slide' && selectedSlide">
-      <div>预览:</div>
+      <div>{{ lang.ssPreview }}:</div>
       <ThumbnailSlide class="thumbnail" :slide="selectedSlide" :size="500" />
     </div>
 
     <div class="btns">
-      <Button @click="emit('close')" style="margin-right: 10px;">取消</Button>
-      <Button type="primary" @click="save()">确认</Button>
+      <Button @click="emit('close')" style="margin-right: 10px;">{{ lang.ssCancel }}</Button>
+      <Button type="primary" @click="save()">{{ lang.ssConfirm }}</Button>
     </div>
   </div>
 </template>
@@ -40,6 +40,7 @@ import { storeToRefs } from 'pinia'
 import { useMainStore, useSlidesStore } from '@/store'
 import type { ElementLinkType, PPTElementLink } from '@/types/slides'
 import useLink from '@/hooks/useLink'
+import { lang } from '@/main'
 
 import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
 import Tabs from '@/components/Tabs.vue'
@@ -68,7 +69,7 @@ const inputRef = useTemplateRef<InstanceType<typeof Input>>('inputRef')
 
 const slideOptions = computed(() => {
   return slides.value.map((item, index) => ({
-    label: `幻灯片 ${index + 1}`,
+    label: lang.ssSlideNum.replace(/\*/g, String(index + 1)),
     value: item.id,
     disabled: currentSlide.value.id === item.id,
   }))
@@ -83,8 +84,8 @@ const selectedSlide = computed(() => {
 })
 
 const tabs: TabItem[] = [
-  { key: 'web', label: '网页链接' },
-  { key: 'slide', label: '幻灯片页面' },
+  { key: 'web', label: lang.ssTabWebLink },
+  { key: 'slide', label: lang.ssTabSlidePg },
 ]
 
 const { setLink } = useLink()

+ 4 - 3
src/views/Editor/Canvas/Operate/LinkHandler.vue

@@ -1,11 +1,11 @@
 <template>
   <div class="link-handler" :style="{ top: height * canvasScale + 10 + 'px' }">
     <a class="link" v-if="link.type === 'web'" :href="link.target" target="_blank">{{link.target}}</a>
-    <a class="link" v-else @click="turnTarget(link.target)">幻灯片页面 {{link.target}}</a>
+    <a class="link" v-else @click="turnTarget(link.target)">{{ lang.ssSlidePg.replace(/\*/g, link.target) }}</a>
     <div class="btns">
-      <div class="btn" @click="openLinkDialog()">更换</div>
+      <div class="btn" @click="openLinkDialog()">{{ lang.ssReplace }}</div>
       <Divider type="vertical" />
-      <div class="btn" @click="removeLink(elementInfo)">移除</div>
+      <div class="btn" @click="removeLink(elementInfo)">{{ lang.ssRemove }}</div>
     </div>
   </div>
 </template>
@@ -16,6 +16,7 @@ import { storeToRefs } from 'pinia'
 import { useMainStore, useSlidesStore } from '@/store'
 import type { PPTElement, PPTElementLink } from '@/types/slides'
 import useLink from '@/hooks/useLink'
+import { lang } from '@/main'
 import Divider from '@/components/Divider.vue'
 
 const props = defineProps<{

+ 2 - 1
src/views/Editor/Canvas/ShapeCreateCanvas.vue

@@ -24,6 +24,7 @@ import { useKeyboardStore, useMainStore, useSlidesStore } from '@/store'
 import type { CreateCustomShapeData } from '@/types/edit'
 import { KEYS } from '@/configs/hotkey'
 import message from '@/utils/message'
+import { lang } from '@/main'
 
 const emit = defineEmits<{
   (event: 'created', payload: CreateCustomShapeData): void
@@ -165,7 +166,7 @@ const keydownListener = (e: KeyboardEvent) => {
   if (key === KEYS.ENTER) create()
 }
 onMounted(() => {
-  message.success('点击绘制任意形状,首尾闭合完成绘制,按 ESC 键或鼠标右键取消,按 ENTER 键提前完成', {
+  message.success(lang.ssShapeHint, {
     duration: 0,
   })
   document.addEventListener('keydown', keydownListener)

+ 7 - 6
src/views/Editor/Canvas/WebpageLinkEditDialog.vue

@@ -1,18 +1,18 @@
 <template>
   <div class="webpage-link-edit-dialog">
-    <div class="title">修改网页链接</div>
+    <div class="title">{{ lang.ssEditWebLink }}</div>
     
     <Input 
       class="input"
       ref="inputRef"
       v-model:value="url" 
-      placeholder="请输入网页链接地址"
+      :placeholder="lang.ssWebUrlPh"
       @enter="save()"
     />
 
     <div class="btns">
-      <Button @click="emit('close')" style="margin-right: 10px;">取消</Button>
-      <Button type="primary" @click="save()">确认</Button>
+      <Button @click="emit('close')" style="margin-right: 10px;">{{ lang.ssCancel }}</Button>
+      <Button type="primary" @click="save()">{{ lang.ssConfirm }}</Button>
     </div>
   </div>
 </template>
@@ -22,6 +22,7 @@ import { onMounted, ref, nextTick } from 'vue'
 import { useSlidesStore } from '@/store'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
 import message from '@/utils/message'
+import { lang } from '@/main'
 
 import Input from '@/components/Input.vue'
 import Button from '@/components/Button.vue'
@@ -49,7 +50,7 @@ onMounted(() => {
 
 const save = () => {
   if (!url.value) {
-    message.error('请输入网页链接地址')
+    message.error(lang.ssWebUrlReq)
     return
   }
 
@@ -58,7 +59,7 @@ const save = () => {
     new URL(url.value)
   }
   catch {
-    message.error('请输入正确的网页链接格式')
+    message.error(lang.ssWebUrlInvalid)
     return
   }
 

+ 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()
     }

+ 20 - 13
src/views/Editor/Canvas/index.vue

@@ -12,7 +12,7 @@
     <div v-if="isCourseLoading" class="fullscreen-loading-overlay">
       <div class="loading-content">
         <div class="loading-spinner"></div>
-        <div class="loading-text">正在加载课程内容...</div>
+        <div class="loading-text">{{ lang.ssCourseLoading }}</div>
       </div>
     </div>
 
@@ -162,6 +162,7 @@ import Modal from '@/components/Modal.vue'
 import api from '@/services/course'
 import useImport from '@/hooks/useImport'
 import message from '@/utils/message'
+import { lang } from '@/main'
 
 
 // 定义组件props
@@ -173,6 +174,10 @@ const props = withDefaults(defineProps<Props>(), {
   courseid: null,
 })
 
+const emit = defineEmits<{
+  (e: 'courseLoaded', data: any): void
+}>()
+
 const mainStore = useMainStore()
 const {
   activeElementIdList,
@@ -266,6 +271,8 @@ const getCourseDetail = async () => {
     const res = await api.getCourseDetail(props.courseid as string)
     console.log(res)
     const courseDetail = res[0][0]
+    emit('courseLoaded', courseDetail)
+    
     const pptJSONUrl = JSON.parse(courseDetail.chapters).pptData ? JSON.parse(courseDetail.chapters).pptData : ''
     console.log(pptJSONUrl)
     
@@ -287,7 +294,7 @@ const getCourseDetail = async () => {
         }
         catch (e) {
           console.error('解析pptdata.data失败:', e)
-          message.error('解析PPT数据失败')
+          message.error(lang.ssParsePptFail)
           if (typeof window !== 'undefined') {
             const win = window as any
             win.pptLoading = 2
@@ -304,7 +311,7 @@ const getCourseDetail = async () => {
   }
   catch (error) {
     console.error('获取课程详情失败:', error)
-    message.error('获取课程详情失败')
+    message.error(lang.ssFetchCourseFail)
     isCourseLoading.value = false
     if (typeof window !== 'undefined') {
       const win = window as any
@@ -411,53 +418,53 @@ const insertCustomShape = (data: CreateCustomShapeData) => {
 const contextmenus = (): ContextmenuItem[] => {
   return [
     {
-      text: '粘贴',
+      text: lang.ssPaste,
       subText: 'Ctrl + V',
       handler: pasteElement,
     },
     {
-      text: '全选',
+      text: lang.ssSelectAll,
       subText: 'Ctrl + A',
       handler: selectAllElements,
     },
     {
-      text: '标尺',
+      text: lang.ssRuler,
       subText: showRuler.value ? '√' : '',
       handler: toggleRuler,
     },
     {
-      text: '网格线',
+      text: lang.ssGridLine,
       handler: () => mainStore.setGridLineSize(gridLineSize.value ? 0 : 50),
       children: [
         {
-          text: '无',
+          text: lang.ssNone,
           subText: gridLineSize.value === 0 ? '√' : '',
           handler: () => mainStore.setGridLineSize(0),
         },
         {
-          text: '小',
+          text: lang.ssSmall,
           subText: gridLineSize.value === 25 ? '√' : '',
           handler: () => mainStore.setGridLineSize(25),
         },
         {
-          text: '中',
+          text: lang.ssMedium,
           subText: gridLineSize.value === 50 ? '√' : '',
           handler: () => mainStore.setGridLineSize(50),
         },
         {
-          text: '大',
+          text: lang.ssLarge,
           subText: gridLineSize.value === 100 ? '√' : '',
           handler: () => mainStore.setGridLineSize(100),
         },
       ],
     },
     {
-      text: '重置当前页',
+      text: lang.ssResetPage,
       handler: deleteAllElements,
     },
     { divider: true },
     {
-      text: '幻灯片放映',
+      text: lang.ssStage,
       subText: 'F5',
       handler: enterScreeningFromStart,
     },

+ 4 - 2
src/views/Editor/CanvasTool/ChartPool.vue

@@ -1,5 +1,5 @@
 <template>
-  <ul class="chart-pool">
+  <ul class="chart-pool" :style="{width: lang.lang === 'en' ? '365px' : '240px'}">
     <li class="chart-item" v-for="(chart, index) in chartList" :key="index">
       <div class="chart-content" @click="selectChart(chart)">
         <IconChartLine size="24" v-if="chart === 'line'" />
@@ -19,7 +19,9 @@
 
 <script lang="ts" setup>
 import type { ChartType } from '@/types/slides'
-import { CHART_TYPE_MAP } from '@/configs/chart'
+import { getChartTypeMap } from '@/configs/chart'
+import { lang } from '@/main'
+const CHART_TYPE_MAP = getChartTypeMap()
 
 const emit = defineEmits<{
   (event: 'select', payload: ChartType): void

+ 11 - 10
src/views/Editor/CanvasTool/MediaInput.vue

@@ -7,18 +7,18 @@
     />
 
     <template v-if="type === 'video'">
-      <Input v-model:value="videoSrc" placeholder="请输入视频地址,e.g. https://xxx.mp4"></Input>
+      <Input v-model:value="videoSrc" :placeholder="lang.ssVideoUrlPh"></Input>
       <div class="btns">
-        <Button @click="emit('close')" style="margin-right: 10px;">取消</Button>
-        <Button type="primary" @click="insertVideo()">确认</Button>
+        <Button @click="emit('close')" style="margin-right: 10px;">{{ lang.ssCancel }}</Button>
+        <Button type="primary" @click="insertVideo()">{{ lang.ssInsert }}</Button>
       </div>
     </template>
 
     <template v-if="type === 'audio'">
-      <Input v-model:value="audioSrc" placeholder="请输入音频地址,e.g. https://xxx.mp3"></Input>
+      <Input v-model:value="audioSrc" :placeholder="lang.ssAudioUrlPh"></Input>
       <div class="btns">
-        <Button @click="emit('close')" style="margin-right: 10px;">取消</Button>
-        <Button type="primary" @click="insertAudio()">确认</Button>
+        <Button @click="emit('close')" style="margin-right: 10px;">{{ lang.ssCancel }}</Button>
+        <Button type="primary" @click="insertAudio()">{{ lang.ssInsert }}</Button>
       </div>
     </template>
   </div>
@@ -30,6 +30,7 @@ import message from '@/utils/message'
 import Tabs from '@/components/Tabs.vue'
 import Input from '@/components/Input.vue'
 import Button from '@/components/Button.vue'
+import { lang } from '@/main'
 
 type TypeKey = 'video' | 'audio'
 interface TabItem {
@@ -49,17 +50,17 @@ const videoSrc = ref('https://asset.pptist.cn/video/example.webm')
 const audioSrc = ref('https://asset.pptist.cn/audio/example.mp3')
 
 const tabs: TabItem[] = [
-  { key: 'video', label: '视频' },
-  { key: 'audio', label: '音频' },
+  { key: 'video', label: lang.ssVideo },
+  { key: 'audio', label: lang.ssAudio },
 ]
 
 const insertVideo = () => {
-  if (!videoSrc.value) return message.error('请先输入正确的视频地址')
+  if (!videoSrc.value) return message.error(lang.ssVideoUrlReq)
   emit('insertVideo', videoSrc.value)
 }
 
 const insertAudio = () => {
-  if (!audioSrc.value) return message.error('请先输入正确的音频地址')
+  if (!audioSrc.value) return message.error(lang.ssAudioUrlReq)
   emit('insertAudio', audioSrc.value)
 }
 </script>

+ 1 - 1
src/views/Editor/CanvasTool/ShapePool.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="shape-pool">
     <div class="category" v-for="item in SHAPE_LIST" :key="item.type">
-      <div class="category-name">{{item.type}}</div>
+      <!-- <div class="category-name">{{item.type}}</div> -->
       <div class="shape-list">
         <ShapeItemThumbnail 
           class="shape-item"

+ 9 - 8
src/views/Editor/CanvasTool/TableGenerator.vue

@@ -1,8 +1,8 @@
 <template>
   <div class="table-generator">
     <div class="title">
-      <div class="lef">表格 {{endCell.length ? `${endCell[0]} x ${endCell[1]}` : ''}}</div>
-      <div class="right" @click="isCustom = !isCustom">{{ isCustom ? '返回' : '自定义'}}</div>
+      <div class="lef">{{ lang.ssTable }} {{endCell.length ? `${endCell[0]} x ${endCell[1]}` : ''}}</div>
+      <div class="right" @click="isCustom = !isCustom">{{ isCustom ? lang.ssBack : lang.ssCustom }}</div>
     </div>
     <table 
       @mouseleave="endCell = []" 
@@ -26,7 +26,7 @@
 
     <div class="custom" v-else>
       <div class="row">
-        <div class="label" style="width: 25%;">行数:</div>
+        <div class="label" style="width: 25%;">{{ lang.ssRowCnt }}</div>
         <NumberInput
           :min="1"
           :max="20"
@@ -35,7 +35,7 @@
         />
       </div>
       <div class="row">
-        <div class="label" style="width: 25%;">列数:</div>
+        <div class="label" style="width: 25%;">{{ lang.ssColCnt }}</div>
         <NumberInput
           :min="1"
           :max="20"
@@ -44,8 +44,8 @@
         />
       </div>
       <div class="btns">
-        <Button class="btn" @click="close()">取消</Button>
-        <Button class="btn" type="primary" @click="insertCustomTable()">确认</Button>
+        <Button class="btn" @click="close()">{{ lang.ssCancel }}</Button>
+        <Button class="btn" type="primary" @click="insertCustomTable()">{{ lang.ssInsert }}</Button>
       </div>
     </div>
   </div>
@@ -56,6 +56,7 @@ import { ref } from 'vue'
 import message from '@/utils/message'
 import Button from '@/components/Button.vue'
 import NumberInput from '@/components/NumberInput.vue'
+import { lang } from '@/main'
 
 interface InsertData {
   row: number
@@ -79,8 +80,8 @@ const handleClickTable = () => {
 }
 
 const insertCustomTable = () => {
-  if (customRow.value < 1 || customRow.value > 20) return message.warning('行数/列数必须在0~20之间!')
-  if (customCol.value < 1 || customCol.value > 20) return message.warning('行数/列数必须在0~20之间!')
+  if (customRow.value < 1 || customRow.value > 20) return message.warning(lang.ssTblRangeWarn)
+  if (customCol.value < 1 || customCol.value > 20) return message.warning(lang.ssTblRangeWarn)
   emit('insert', { row: customRow.value, col: customCol.value })
   isCustom.value = false
 }

+ 32 - 13
src/views/Editor/CanvasTool/WebpageInput.vue

@@ -1,13 +1,13 @@
 <template>
   <div class="webpage-input">
-    <div class="title">插入学习内容</div>
-    <div class="description">请选择要嵌入的学习内容</div>
+    <div class="title">{{ lang.ssInsertLearn }}</div>
+    <div class="description">{{ lang.ssPickLearn }}</div>
     
     <!-- 当列表为空时显示上传提示 -->
     <div v-if="webpageList.length === 0" class="empty-state">
       <div class="empty-icon">📚</div>
-      <div class="empty-title">暂无学习内容</div>
-      <div class="empty-desc">请先上传或创建学习内容</div>
+      <div class="empty-title">{{ lang.ssNoLearn }}</div>
+      <div class="empty-desc">{{ lang.ssNeedUpload }}</div>
     </div>
     
     <!-- 当有内容时显示列表 -->
@@ -33,14 +33,14 @@
     </div>
     
     <div class="btns">
-      <Button @click="emit('close')" style="margin-right: 10px;">取消</Button>
+      <Button @click="emit('close')" style="margin-right: 10px;">{{ lang.ssCancel }}</Button>
       <Button 
         v-if="webpageList.length > 0"
         type="primary" 
         @click="insertWebpage()"
         :disabled="selectedIndex === null"
       >
-        确认
+        {{ lang.ssConfirm }}
       </Button>
     </div>
   </div>
@@ -50,6 +50,7 @@
 import { ref } from 'vue'
 import message from '@/utils/message'
 import Button from '@/components/Button.vue'
+import { lang } from '@/main'
 
 interface Webpage {
   type: number
@@ -85,7 +86,7 @@ const toggleWebpageSelection = (index: number) => {
 
 const insertWebpage = () => {
   if (selectedIndex.value === null) {
-    return message.error('请先选择一个网页')
+    return message.error(lang.ssPickWebFirst)
   }
   
   // 根据选中的index获取对应的链接
@@ -98,12 +99,15 @@ const insertWebpage = () => {
 // 获取类型标签
 const getTypeLabel = (type: number) => {
   const typeMap: Record<number, string> = {
-    45: '选择题',
-    15: '问答题',
-    72: 'AI应用',
-    73: 'H5页面'
+    45: lang.ssChoiceQ,
+    15: lang.ssQATest,
+    72: lang.ssAiApp,
+    73: lang.ssHPage,
+    74: lang.ssVideo,
+    75: lang.ssBiliVideo,
+    76: lang.ssCreative
   }
-  return typeMap[type] || '未知'
+  return typeMap[type] || lang.ssUnknown
 }
 
 // 获取类型样式类
@@ -112,7 +116,10 @@ const getTypeClass = (type: number) => {
     45: 'type-choice',
     15: 'type-question',
     72: 'type-ai',
-    73: 'type-h5'
+    73: 'type-h5',
+    74: 'type-video',
+    75: 'type-bilibili',
+    76: 'type-app-center'
   }
   return classMap[type] || 'type-default'
 }
@@ -256,9 +263,21 @@ const getTypeClass = (type: number) => {
     background-color: #9c27b0;
   }
   
+  &.type-video {
+    background-color: #f44336;
+  }
+  
+  &.type-bilibili {
+    background-color: #fb7299;
+  }
+  
   &.type-default {
     background-color: #757575;
   }
+  
+  &.type-app-center {
+    background-color: #673ab7;
+  }
 }
 
 .webpage-url {

+ 70 - 27
src/views/Editor/CanvasTool/index.vue

@@ -1,37 +1,37 @@
 <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="lang.ssUndoTip" @click="undo()" />
+      <IconNext class="handler-item" :class="{ 'disable': !canRedo }" v-tooltip="lang.ssRedoTip" @click="redo()" />
       <div class="more">
         <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">{{ lang.ssNotePanel }}</PopoverMenuItem>
+            <PopoverMenuItem center @click="toggleSelectPanel(); moreVisible = false">{{ lang.ssSelectPane }}</PopoverMenuItem>
+            <PopoverMenuItem center @click="toggleSraechPanel(); moreVisible = false">{{ lang.ssSearchReplace }}</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="lang.ssNotePanel" @click="toggleNotesPanel()" />
+        <IconMoveOne class="handler-item" :class="{ 'active': showSelectPanel }" v-tooltip="lang.ssSelectPane" @click="toggleSelectPanel()" />
+        <IconSearch class="handler-item" :class="{ 'active': showSearchPanel }" v-tooltip="lang.ssSearchTip" @click="toggleSraechPanel()" />
       </div>
     </div>
 
     <div class="add-element-handler">
-      <div class="handler-item group-btn" v-tooltip="'插入文字'">
+      <div class="handler-item group-btn" v-tooltip="lang.ssInsertText">
         <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 /> {{ lang.ssTextHorizontal }}</PopoverMenuItem>
+            <PopoverMenuItem center @click="() => { drawText(true); textTypeSelectVisible = false }"><IconTextRotationDown /> {{ lang.ssTextVertical }}</PopoverMenuItem>
           </template>
           <IconDown class="arrow" />
         </Popover>
       </div>
-      <div class="handler-item group-btn" v-tooltip="'插入形状'" :offset="10">
+      <div class="handler-item group-btn" v-tooltip="lang.ssInsertShape" :offset="10">
         <Popover trigger="click" style="height: 100%;" v-model:value="shapePoolVisible" :offset="10">
           <template #content>
             <ShapePool @select="shape => drawShape(shape)" />
@@ -41,25 +41,25 @@
         
         <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 }">{{ lang.ssFreeDraw }}</PopoverMenuItem>
           </template>
           <IconDown class="arrow" />
         </Popover>
       </div>
       <FileInput @change="files => insertImageElement(files)">
-        <IconPicture class="handler-item" v-tooltip="'插入图片'" />
+        <IconPicture class="handler-item" v-tooltip="lang.ssInsertImage" />
       </FileInput>
       <Popover trigger="click" v-model:value="linePoolVisible" :offset="10">
         <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="lang.ssInsertLine" />
       </Popover>
       <Popover trigger="click" v-model:value="chartPoolVisible" :offset="10">
         <template #content>
           <ChartPool @select="chart => { createChartElement(chart); chartPoolVisible = false }" />
         </template>
-        <IconChartProportion class="handler-item" v-tooltip="'插入图表'" />
+        <IconChartProportion class="handler-item" v-tooltip="lang.ssInsertChart" />
       </Popover>
       <Popover trigger="click" v-model:value="tableGeneratorVisible" :offset="10">
         <template #content>
@@ -68,10 +68,10 @@
             @insert="({ row, col }) => { createTableElement(row, col); tableGeneratorVisible = false }"
           />
         </template>
-        <IconInsertTable class="handler-item" v-tooltip="'插入表格'" />
+        <IconInsertTable class="handler-item" v-tooltip="lang.ssInsertTable" />
       </Popover>
-      <IconFormula class="handler-item" v-tooltip="'插入公式'" @click="latexEditorVisible = true" />
-      <Popover trigger="manual" v-model:value="webpageInputVisible" :offset="10">
+      <IconFormula class="handler-item" v-tooltip="lang.ssInsertFormula" @click="latexEditorVisible = true" />
+      <Popover v-if="viewMode !== 'editor2'" trigger="manual" v-model:value="webpageInputVisible" :offset="10">
         <template #content>
           <WebpageInput 
             :webpageList="webpageList"
@@ -79,7 +79,7 @@
             @insertWebpage="({ url, type }) => { createFrameElement(url, type); webpageInputVisible = false }"
           />
         </template>
-        <IconLinkOne class="handler-item" v-tooltip="'插入学习内容'" @click="handleInsertLearningContent" />
+        <IconLinkOne class="handler-item" v-tooltip="lang.ssInsertLearn" @click="handleInsertLearningContent" />
       </Popover>
       <Popover trigger="click" v-model:value="mediaInputVisible" :offset="10">
         <template #content>
@@ -89,12 +89,13 @@
             @insertAudio="src => { createAudioElement(src); mediaInputVisible = false }"
           />
         </template>
-        <IconVideoTwo class="handler-item" v-tooltip="'插入音视频'" />
+        <IconVideoTwo class="handler-item" v-tooltip="lang.ssInsertMedia" />
       </Popover>
     </div>
 
     <div class="right-handler">
-      <IconMinus class="handler-item viewport-size" v-tooltip="'画布缩小(Ctrl + -)'" @click="scaleCanvas('-')" />
+      <div v-if="hasInteractiveTool" class="handler-item viewport-size edit-tool-btn" @click="editTool">{{ lang.ssEditTool }}</div>
+      <IconMinus class="handler-item viewport-size" v-tooltip="lang.ssZoomOutTip" @click="scaleCanvas('-')" />
       <Popover trigger="click" v-model:value="canvasScaleVisible">
         <template #content>
           <PopoverMenuItem
@@ -103,12 +104,12 @@
             :key="item" 
             @click="applyCanvasPresetScale(item)"
           >{{item}}%</PopoverMenuItem>
-          <PopoverMenuItem center @click="resetCanvas(); canvasScaleVisible = false">适应屏幕</PopoverMenuItem>
+          <PopoverMenuItem center @click="resetCanvas(); canvasScaleVisible = false">{{ lang.ssFitScreen }}</PopoverMenuItem>
         </template>
         <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()" />
+      <IconPlus class="handler-item viewport-size" v-tooltip="lang.ssZoomInTip" @click="scaleCanvas('+')" />
+      <IconFullScreen class="handler-item viewport-size-adaptation" v-tooltip="lang.ssFitScreenTip" @click="resetCanvas()" />
     </div>
 
     <Modal
@@ -124,15 +125,16 @@
 </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'
 import useScaleCanvas from '@/hooks/useScaleCanvas'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
 import useCreateElement from '@/hooks/useCreateElement'
+import { lang } from '@/main'
 
 import ShapePool from './ShapePool.vue'
 import LinePool from './LinePool.vue'
@@ -148,8 +150,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 +432,11 @@ const toggleNotesPanel = () => {
   }
 }
 
+.edit-tool-btn{
+  color: #285cf5;
+  cursor: pointer;
+}
+
 @media screen and (width <= 1200px) {
   .right-handler .text {
     display: none;

+ 578 - 0
src/views/Editor/CanvasTool/index2.vue

@@ -0,0 +1,578 @@
+<template>
+  <div class="canvas-tool">
+    <div class="left-handler">
+      <Popover trigger="click" v-model:value="toolVisible"
+        style="height: 100%;display: flex;align-items: center;" :offset="10" v-if="hasInteractiveTool">
+        <template #content>
+          <div class="popover-item" @click="editContent(45)" v-if="frametype != 45">
+            <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <circle cx="12" cy="12" r="10" />
+              <path d="M12 16v-4m0-4h.01" />
+            </svg>
+            <span>{{ lang.ssChoiceQ }}</span>
+          </div>
+          <div class="popover-item" @click="editContent(15)" v-if="frametype != 15">
+            <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
+            </svg>
+            <span>{{ lang.ssQandA }}</span>
+          </div>
+        </template>
+        <div class="handler-item">
+          <span class="svg-icon">
+            <svg v-if="frametype == 45" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <circle cx="12" cy="12" r="10" />
+              <path d="M12 16v-4m0-4h.01" />
+            </svg>
+            <svg v-if="frametype == 15" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
+            </svg>
+          </span>
+          <span>{{ iframeLabel }}</span>
+        </div>
+      </Popover>
+      <Popover trigger="click" v-model:value="textTypeSelectVisible"
+        style="height: 100%;display: flex;align-items: center;" :offset="10" v-if="!isFrame">
+        <template #content>
+          <PopoverMenuItem center @click="() => { drawText(); textTypeSelectVisible = false }">
+            <IconTextRotationNone /> {{ lang.ssTextHorizontal }}
+          </PopoverMenuItem>
+          <PopoverMenuItem center @click="() => { drawText(true); textTypeSelectVisible = false }">
+            <IconTextRotationDown /> {{ lang.ssTextVertical }}
+          </PopoverMenuItem>
+        </template>
+        <div class="handler-item">
+          <IconFontSize class="icon" :class="{ 'active': creatingElement?.type === 'text' }" @click="drawText()" />
+          <span>{{ lang.ssText }}</span>
+        </div>
+      </Popover>
+      <Popover trigger="click" style="height: 100%;display: flex;align-items: center;"
+        v-model:value="picturePoolVisible" :offset="10" v-if="!isFrame">
+        <template #content>
+          <FileInput @change="files => insertImageElement(files)">
+            <div class="popover-item">
+              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <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>
+              <span>{{ lang.ssUploadFromLocal }}</span>
+            </div>
+          </FileInput>
+          <!-- <div class="popover-item">
+            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+              stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+              <circle cx="11" cy="11" r="8" />
+              <path d="M21 21l-4.35-4.35" />
+            </svg>
+            <span>{{ lang.ssSearchFromWeb }}</span>
+          </div>
+          <div class="popover-item">
+            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+              stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+              <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>
+            <span>{{ lang.ssGenerateFromAI }}</span>
+          </div> -->
+        </template>
+        <div class="handler-item">
+          <IconPicture class="icon" v-tooltip="lang.ssInsertImage" />
+          <span>{{ lang.ssImage }}</span>
+        </div>
+        <!-- <FileInput @change="files => insertImageElement(files)">
+          </FileInput> -->
+      </Popover>
+      <Popover trigger="click" style="height: 100%;display: flex;align-items: center;" v-model:value="shapePoolVisible"
+        :offset="10" v-if="!isFrame">
+        <template #content>
+          <ShapePool @select="shape => drawShape(shape)" />
+        </template>
+        <div class="handler-item">
+          <IconGraphicDesign class="icon"
+            :class="{ 'active': creatingCustomShape || creatingElement?.type === 'shape' }" />
+          <span>{{ lang.ssShape }}</span>
+        </div>
+      </Popover>
+    </div>
+
+
+    <Modal v-model:visible="latexEditorVisible" :width="880">
+      <LaTeXEditor @close="latexEditorVisible = false"
+        @update="data => { createLatexElement(data); latexEditorVisible = false }" />
+    </Modal>
+
+    <!-- 确认对话框 -->
+    <Modal
+      :visible="confirmDialogVisible"
+      :width="420"
+      :closeButton="true"
+      :closeOnClickMask="false"
+      :closeOnEsc="false"
+      @update:visible="val => confirmDialogVisible = val"
+    >
+      <div class="clear-confirm">
+        <div class="clear-confirm__title">{{ lang.ssConfirmOperation }}</div>
+        <div class="clear-confirm__content">
+          {{ lang.ssClearToolContent }}
+        </div>
+        <div class="clear-confirm__footer">
+          <Button type="default" @click="handleCancel">{{ lang.ssCancel }}</Button>
+          <Button type="primary" @click="handleConfirm">{{ lang.ssApply }}</Button>
+        </div>
+      </div>
+    </Modal>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue'
+import { storeToRefs } from 'pinia'
+import { useMainStore, useSnapshotStore, useSlidesStore } from '@/store'
+import { getImageDataURL } from '@/utils/image'
+import useImport from '@/hooks/useImport'
+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 { lang } from '@/main'
+
+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 WebpageInput from './WebpageInput.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 Button from '@/components/Button.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 iframeLabel = computed(() => {
+  return getTypeLabel(frametype.value)
+})
+
+const frametype = computed(() => {
+  const elements = currentSlide.value?.elements || []
+  return hasInteractiveTool.value ? elements[0].type === 'frame' ? elements[0]?.toolType || 0 : 0 : 0
+})
+
+// 获取类型标签
+const getTypeLabel = (type: number) => {
+  const typeMap: Record<number, string> = {
+    45: lang.ssChoiceQ,
+    15: lang.ssQATest,
+    72: lang.ssAiApp,
+    73: lang.ssHPage,
+    74: lang.ssVideo,
+    75: lang.ssBiliVideo,
+    76: lang.ssCreative
+  }
+  return typeMap[type] || lang.ssUnknown
+}
+
+const isFrame = computed(() => {
+  const elements = currentSlide.value?.elements || []
+  return elements.some((el: any) => el.type === 'frame')
+})
+
+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()
+
+const {
+  scaleCanvas,
+  setCanvasScalePercentage,
+  resetCanvas,
+  canvasScalePercentage,
+} = useScaleCanvas()
+
+const canvasScalePresetList = [200, 150, 125, 100, 75, 50]
+const canvasScaleVisible = ref(false)
+
+const applyCanvasPresetScale = (value: number) => {
+  setCanvasScalePercentage(value)
+  canvasScaleVisible.value = false
+}
+
+const {
+  createImageElement,
+  createChartElement,
+  createTableElement,
+  createLatexElement,
+  createVideoElement,
+  createAudioElement,
+  createFrameElement,
+} = useCreateElement()
+
+const { uploadFileToS3 } = useImport()
+const insertImageElement = async (files: FileList) => {
+  const imageFile = files[0]
+  if (!imageFile) return
+  const url = await uploadFileToS3(imageFile)
+  console.log(url)
+
+  // getImageDataURL(imageFile).then(dataURL => createImageElement(dataURL))
+  createImageElement(url)
+  picturePoolVisible.value = false
+}
+
+const shapePoolVisible = ref(false)
+const picturePoolVisible = ref(false)
+const linePoolVisible = ref(false)
+const chartPoolVisible = ref(false)
+const tableGeneratorVisible = ref(false)
+const mediaInputVisible = ref(false)
+const webpageInputVisible = ref(false)
+
+// 预设的网页列表
+const webpageList = ref<Array<{ type: number, title: string, url: string }>>([])
+
+// 点击插入学习内容时获取数据
+const handleInsertLearningContent = () => {
+  try {
+    // 获取父窗口的工具列表,如果没有则使用空数组
+    // 定义父窗口的类型,添加pptToolList属性
+    interface ParentWindowWithToolList extends Window {
+      pptToolList?: Array<{ tool?: number; title?: string; url?: string }>
+    }
+    const parentWindow = window.parent as ParentWindowWithToolList
+    const pptToolList = parentWindow?.pptToolList || []
+    // 转换父窗口的工具列表格式
+    webpageList.value = pptToolList.map((item: any) => ({
+      type: item.tool || 0,
+      title: item.title || lang.ssUnknownTool,
+      url: item.url || '#'
+    })).filter((item: any) => item.url !== '#') // 过滤掉无效的URL
+
+    console.log('学习内容列表加载完成:', webpageList.value.length, '个项目')
+
+    // 显示弹窗
+    webpageInputVisible.value = true
+
+  }
+  catch (error) {
+    console.error('加载学习内容失败:', error)
+
+    // 发生错误时使用空数组
+    webpageList.value = []
+
+    // 显示弹窗
+    webpageInputVisible.value = true
+  }
+}
+const latexEditorVisible = ref(false)
+const textTypeSelectVisible = ref(false)
+const toolVisible = ref(false)
+const shapeMenuVisible = ref(false)
+const moreVisible = ref(false)
+
+// 绘制文字范围
+const drawText = (vertical = false) => {
+  mainStore.setCreatingElement({
+    type: 'text',
+    vertical,
+  })
+}
+
+// 绘制形状范围
+const drawShape = (shape: ShapePoolItem) => {
+  mainStore.setCreatingElement({
+    type: 'shape',
+    data: shape,
+  })
+  shapePoolVisible.value = false
+}
+// 绘制自定义任意多边形
+const drawCustomShape = () => {
+  mainStore.setCreatingCustomShapeState(true)
+  shapePoolVisible.value = false
+}
+
+// 绘制线条路径
+const drawLine = (line: LinePoolItem) => {
+  mainStore.setCreatingElement({
+    type: 'line',
+    data: line,
+  })
+  linePoolVisible.value = false
+}
+
+
+
+// 打开选择面板
+const toggleSelectPanel = () => {
+  mainStore.setSelectPanelState(!showSelectPanel.value)
+}
+
+// 打开搜索替换面板
+const toggleSraechPanel = () => {
+  mainStore.setSearchPanelState(!showSearchPanel.value)
+}
+
+// 打开批注面板
+const toggleNotesPanel = () => {
+  mainStore.setNotesPanelState(!showNotesPanel.value)
+}
+
+interface ContentItem {
+  tool?: number
+  title?: string
+  url?: string
+  id?: string
+}
+
+
+// 确认对话框状态
+const confirmDialogVisible = ref(false)
+const confirmDialogCallback = ref<(() => void) | null>(null)
+
+const showConfirmDialog = (callback: () => void) => {
+  confirmDialogCallback.value = callback
+  confirmDialogVisible.value = true
+}
+
+const handleConfirm = () => {
+  confirmDialogCallback.value?.()
+  confirmDialogVisible.value = false
+  confirmDialogCallback.value = null
+}
+
+const handleCancel = () => {
+  confirmDialogVisible.value = false
+  confirmDialogCallback.value = null
+}
+
+const editContent = (toolType: number) => {
+  showConfirmDialog(() => {
+    interface ParentWindowWithToolList extends Window {
+      toolBtn2?: (action: number, id: string, toolType: number) => void;
+    }
+    const parentWindow = window.parent as ParentWindowWithToolList
+    const elements = currentSlide.value?.elements || []
+    const frameElement = elements.find((el: any) => el.type === 'frame' && (el.toolType === 45 || el.toolType === 15))
+    parentWindow?.toolBtn2?.(0, frameElement?.url || '', toolType)
+    toolVisible.value = false
+  })
+}
+
+</script>
+
+<style lang="scss" scoped>
+.confirm-dialog-content {
+  padding: 20px;
+  text-align: center;
+
+  h3 {
+    margin: 0 0 16px;
+    font-size: 18px;
+    font-weight: 600;
+    color: #333;
+  }
+
+  p {
+    margin: 0 0 24px;
+    font-size: 14px;
+    color: #666;
+    line-height: 1.5;
+  }
+
+  .confirm-dialog-buttons {
+    display: flex;
+    justify-content: center;
+    gap: 16px;
+
+    button {
+      padding: 8px 24px;
+      border-radius: 6px;
+      font-size: 14px;
+      font-weight: 500;
+      cursor: pointer;
+      transition: all 0.3s;
+
+      &.cancel-btn {
+        background: #f5f5f5;
+        color: #666;
+        border: 1px solid #e0e0e0;
+
+        &:hover {
+          background: #e8e8e8;
+        }
+      }
+
+      &.confirm-btn {
+        background: #FF9300;
+        color: white;
+        border: 1px solid #FF9300;
+
+        &:hover {
+          background: #e68a00;
+        }
+      }
+    }
+  }
+}
+
+.canvas-tool {
+  position: relative;
+  border-bottom: 1px solid $borderColor;
+  background-color: #fff;
+  display: flex;
+  padding: 0 10px;
+  font-size: 13px;
+  user-select: none;
+}
+
+.left-handler {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+
+  .handler-item {
+    // width: 32px;
+    background-color: #f9fafb;
+    border-radius: 5px;
+    transition: all 0.3s ease;
+
+    &:not(.group-btn):hover {
+      background-color: #f1f1f1;
+    }
+
+    &.active {
+      color: $themeColor;
+    }
+
+    .icon {
+      margin-right: 5px;
+    }
+
+    .svg-icon {
+      margin-right: 5px;
+      display: flex;
+    }
+  }
+}
+
+.handler-item {
+  height: 35px;
+  font-size: 14px;
+  // margin: 0 2px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: $borderRadius;
+  overflow: hidden;
+  cursor: pointer;
+
+  &.disable {
+    opacity: .5;
+  }
+}
+
+.left-handler,
+.right-handler {
+  .handler-item {
+    padding: 0 15px;
+
+    &.active,
+    &:not(.disable):hover {
+      background-color: #f1f1f1;
+    }
+  }
+}
+
+.right-handler {
+  display: flex;
+  align-items: center;
+
+  .text {
+    display: inline-block;
+    width: 40px;
+    text-align: center;
+    cursor: pointer;
+  }
+
+  .viewport-size {
+    font-size: 13px;
+  }
+}
+
+.edit-tool-btn {
+  color: #285cf5;
+  cursor: pointer;
+}
+
+.popover-item {
+  min-width: 80px;
+  padding: 10px 10px;
+  // border-radius: $borderRadius;
+  border-radius: 5px;
+  font-size: 13px;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  display: flex;
+  align-items: center;
+
+  svg {
+    margin-right: 5px;
+    width: 1em;
+    height: 1em;
+  }
+
+  &.center {
+    text-align: center;
+  }
+
+  &:hover {
+    background-color: #f3f4f6;
+  }
+
+  &+.popover-menu-item {
+    margin-top: 2px;
+  }
+}
+</style>

+ 39 - 22
src/views/Editor/EditorHeader/index.vue

@@ -3,36 +3,41 @@
     <div class="left">
       <Popover trigger="click" placement="bottom-start" v-model:value="mainMenuVisible">
         <template #content>
-          <PopoverMenuItem @click="openAIPPTDialog(); mainMenuVisible = false">AI 生成 PPT</PopoverMenuItem>
+          <!-- <PopoverMenuItem @click="openAIPPTDialog(); mainMenuVisible = false">AI 生成 PPT</PopoverMenuItem> -->
           <FileInput accept="application/vnd.openxmlformats-officedocument.presentationml.presentation"  @change="files => {
             importPPTXFile(files)
             mainMenuVisible = false
           }">
-            <PopoverMenuItem>导入 PPTX 文件</PopoverMenuItem>
+            <PopoverMenuItem>{{ lang.ssImportPptx }}</PopoverMenuItem>
           </FileInput>
-          <FileInput accept=".json"  @change="files => {
+          <!-- <FileInput accept=".json"  @change="files => {
             importJSON(files)
             mainMenuVisible = false
           }">
             <PopoverMenuItem>导入 JSON 文件</PopoverMenuItem>
-          </FileInput>
+          </FileInput> -->
           <!-- <FileInput accept=".pptist"  @change="files => {
             importSpecificFile(files)
             mainMenuVisible = false
           }">
             <PopoverMenuItem>导入 pptist 文件</PopoverMenuItem>
           </FileInput> -->
-          <PopoverMenuItem @click="setDialogForExport('pptx')">导出文件</PopoverMenuItem>
-          <PopoverMenuItem @click="resetSlides(); mainMenuVisible = false">重置幻灯片</PopoverMenuItem>
-          <PopoverMenuItem @click="openMarkupPanel(); mainMenuVisible = false">幻灯片类型标注</PopoverMenuItem>
+          <!-- <PopoverMenuItem @click="setDialogForExport('pptx')">导出文件</PopoverMenuItem> -->
+          <PopoverMenuItem @click="resetSlides(); mainMenuVisible = false">{{ lang.ssResetSlides }}</PopoverMenuItem>
+          <!-- <PopoverMenuItem @click="openMarkupPanel(); mainMenuVisible = false">幻灯片类型标注</PopoverMenuItem> -->
           <!-- <PopoverMenuItem @click="goLink('https://github.com/pipipi-pikachu/PPTist/issues')">意见反馈</PopoverMenuItem> -->
           <!-- <PopoverMenuItem @click="goLink('https://github.com/pipipi-pikachu/PPTist/blob/master/doc/Q&A.md')">常见问题</PopoverMenuItem> -->
-          <PopoverMenuItem @click="mainMenuVisible = false; hotkeyDrawerVisible = true">快捷操作</PopoverMenuItem>
+          <PopoverMenuItem @click="mainMenuVisible = false; hotkeyDrawerVisible = true">{{ lang.ssHotkeys }}</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>{{ lang.ssUploadPptx }}</div>
+      </FileInput>
 
-      <div class="title">
+      <div class="title" v-show="false">
         <Input 
           class="title-input" 
           ref="titleInputRef"
@@ -51,13 +56,13 @@
 
     <div class="right">
       <div class="group-menu-item">
-        <div class="menu-item" v-tooltip="'幻灯片放映(F5)'" @click="enterScreening()">
+        <div class="menu-item" v-tooltip="lang.ssStage" @click="enterScreening()">
           <IconPpt class="icon" />
         </div>
         <Popover trigger="click" center>
           <template #content>
-            <PopoverMenuItem @click="enterScreeningFromStart()">从头开始</PopoverMenuItem>
-            <PopoverMenuItem @click="enterScreening()">从当前页开始</PopoverMenuItem>
+            <PopoverMenuItem @click="enterScreeningFromStart()">{{ lang.ssFromStart }}</PopoverMenuItem>
+            <PopoverMenuItem @click="enterScreening()">{{ lang.ssFromCurrent }}</PopoverMenuItem>
           </template>
           <div class="arrow-btn"><IconDown class="arrow" /></div>
         </Popover>
@@ -65,15 +70,15 @@
       <!-- <div class="menu-item" v-tooltip="'学生视图'" @click="enterStudentView()">
         <IconUser class="icon" />
       </div> -->
-      <div class="menu-item" v-tooltip="'AI生成PPT'" @click="openAIPPTDialog(); mainMenuVisible = false">
+      <!-- <div class="menu-item" v-tooltip="'AI生成PPT'" @click="openAIPPTDialog(); mainMenuVisible = false">
         <span class="text ai">AI</span>
-      </div>
-      <div class="menu-item" v-tooltip="'导出'" @click="setDialogForExport('pptx')">
+      </div> -->
+      <div class="menu-item" v-tooltip="lang.ssExport" @click="setDialogForExport('pptx')">
         <IconDownload class="icon" />
       </div>
-      <a class="github-link" v-tooltip="'Copyright © 2020-PRESENT pipipi-pikachu'" href="https://github.com/pipipi-pikachu/PPTist" target="_blank">
+      <!-- <a class="github-link" v-tooltip="'Copyright © 2020-PRESENT pipipi-pikachu'" href="https://github.com/pipipi-pikachu/PPTist" target="_blank">
         <div class="menu-item"><IconGithub class="icon" /></div>
-      </a>
+      </a> -->
     </div>
 
     <Drawer
@@ -82,22 +87,22 @@
       placement="right"
     >
       <HotkeyDoc />
-      <template v-slot:title>快捷操作</template>
+      <template v-slot:title>{{ lang.ssHotkeys }}</template>
     </Drawer>
 
-    <FullscreenSpin :loading="exporting" tip="正在导入..." />
+    <FullscreenSpin :loading="exporting" :tip="lang.ssImporting" />
   </div>
 </template>
 
 <script lang="ts" setup>
-import { nextTick, ref, useTemplateRef, inject } from 'vue'
+import { nextTick, ref, useTemplateRef, inject, onMounted } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useMainStore, useSlidesStore } from '@/store'
 import useScreening from '@/hooks/useScreening'
 import useImport from '@/hooks/useImport'
 import useSlideHandler from '@/hooks/useSlideHandler'
 import type { DialogForExportTypes } from '@/types/export'
-
+import { lang } from '@/main'
 import HotkeyDoc from './HotkeyDoc.vue'
 import FileInput from '@/components/FileInput.vue'
 import FullscreenSpin from '@/components/FullscreenSpin.vue'
@@ -152,6 +157,13 @@ const enterStudentView = () => {
   // 通过路由跳转到学生模式
   window.location.href = '/?mode=student'
 }
+
+// const setTitle = (newTitle: string) => {
+//   titleValue.value = newTitle
+//   slidesStore.setTitle(newTitle)
+// }
+
+// (window as any).setTitle = setTitle
 </script>
 
 <style lang="scss" scoped>
@@ -197,6 +209,11 @@ const enterStudentView = () => {
   &:hover {
     background-color: #f1f1f1;
   }
+
+  svg {
+    width: 18px;
+    margin-right: 5px;
+  }
 }
 .group-menu-item {
   height: 30px;

+ 14 - 13
src/views/Editor/ExportDialog/ExportImage.vue

@@ -11,9 +11,9 @@
         />
       </div>
     </div>
-    <div class="configs">
+    <div class="configs" :style="{ width: lang.lang === 'en' ? '475px' : '350px' }">
       <div class="row">
-        <div class="title">导出格式:</div>
+        <div class="title">{{ lang.ssExpFormat }}</div>
         <RadioGroup
           class="config-item"
           v-model:value="format"
@@ -23,18 +23,18 @@
         </RadioGroup>
       </div>
       <div class="row">
-        <div class="title">导出范围:</div>
+        <div class="title">{{ lang.ssExpRange }}</div>
         <RadioGroup
           class="config-item"
           v-model:value="rangeType"
         >
-          <RadioButton style="width: 33.33%;" value="all">全部</RadioButton>
-          <RadioButton style="width: 33.33%;" value="current">当前页</RadioButton>
-          <RadioButton style="width: 33.33%;" value="custom">自定义</RadioButton>
+          <RadioButton style="width: 33.33%;" value="all">{{ lang.ssShowAll }}</RadioButton>
+          <RadioButton style="width: 33.33%;" value="current">{{ lang.ssCurPage }}</RadioButton>
+          <RadioButton style="width: 33.33%;" value="custom">{{ lang.ssCustom }}</RadioButton>
         </RadioGroup>
       </div>
       <div class="row" v-if="rangeType === 'custom'">
-        <div class="title" :data-range="`(${range[0]} ~ ${range[1]})`">自定义范围:</div>
+        <div class="title" :data-range="`(${range[0]} ~ ${range[1]})`">{{ lang.ssCustomRange }}</div>
         <Slider
           class="config-item"
           range
@@ -46,7 +46,7 @@
       </div>
 
       <div class="row">
-        <div class="title">图片质量:</div>
+        <div class="title">{{ lang.ssImgQuality }}</div>
         <Slider
           class="config-item"
           :min="0"
@@ -57,19 +57,19 @@
       </div>
 
       <div class="row">
-        <div class="title">忽略在线字体:</div>
+        <div class="title">{{ lang.ssIgnoreWebfont }}</div>
         <div class="config-item">
-          <Switch v-model:value="ignoreWebfont" v-tooltip="'导出时默认忽略在线字体,若您在幻灯片中使用了在线字体,且希望导出后保留相关样式,可选择关闭【忽略在线字体】选项,但要注意这将会增加导出用时。'" />
+          <Switch v-model:value="ignoreWebfont" v-tooltip="lang.ssIgnoreWebfontTip" />
         </div>
       </div>
     </div>
 
     <div class="btns">
-      <Button class="btn export" type="primary" @click="expImage()">导出图片</Button>
-      <Button class="btn close" @click="emit('close')">关闭</Button>
+      <Button class="btn export" type="primary" @click="expImage()">{{ lang.ssExportImage }}</Button>
+      <Button class="btn close" @click="emit('close')">{{ lang.ssClose }}</Button>
     </div>
 
-    <FullscreenSpin :loading="exporting" tip="正在导出..." />
+    <FullscreenSpin :loading="exporting" :tip="lang.ssExporting" />
   </div>
 </template>
 
@@ -78,6 +78,7 @@ import { computed, ref, useTemplateRef } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useSlidesStore } from '@/store'
 import useExport from '@/hooks/useExport'
+import { lang } from '@/main'
 
 import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
 import FullscreenSpin from '@/components/FullscreenSpin.vue'

+ 3 - 2
src/views/Editor/ExportDialog/ExportJSON.vue

@@ -5,8 +5,8 @@
     </div>
 
     <div class="btns">
-      <Button class="btn export" type="primary" @click="exportJSON()">导出 JSON</Button>
-      <Button class="btn close" @click="emit('close')">关闭</Button>
+      <Button class="btn export" type="primary" @click="exportJSON()">{{ lang.ssExportJSON }}</Button>
+      <Button class="btn close" @click="emit('close')">{{ lang.ssClose }}</Button>
     </div>
   </div>
 </template>
@@ -16,6 +16,7 @@ import { computed } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useSlidesStore } from '@/store'
 import useExport from '@/hooks/useExport'
+import { lang } from '@/main'
 import Button from '@/components/Button.vue'
 
 const emit = defineEmits<{

+ 10 - 9
src/views/Editor/ExportDialog/ExportPDF.vue

@@ -20,19 +20,19 @@
         </template>
       </div>
     </div>
-    <div class="configs">
+    <div class="configs" :style="{ width: lang.lang === 'en' ? '475px' : '350px' }">
       <div class="row">
-        <div class="title">导出范围:</div>
+        <div class="title">{{ lang.ssExpRange }}</div>
         <RadioGroup
           class="config-item"
           v-model:value="rangeType"
         >
-          <RadioButton style="width: 50%;" value="all">全部</RadioButton>
-          <RadioButton style="width: 50%;" value="current">当前页</RadioButton>
+          <RadioButton style="width: 50%;" value="all">{{ lang.ssShowAll }}</RadioButton>
+          <RadioButton style="width: 50%;" value="current">{{ lang.ssCurPage }}</RadioButton>
         </RadioGroup>
       </div>
       <div class="row">
-        <div class="title">每页数量:</div>
+        <div class="title">{{ lang.ssPerPage }}</div>
         <Select 
           class="config-item"
           v-model:value="count"
@@ -44,19 +44,19 @@
         />
       </div>
       <div class="row">
-        <div class="title">边缘留白:</div>
+        <div class="title">{{ lang.ssEdgePad }}</div>
         <div class="config-item">
           <Switch v-model:value="padding" />
         </div>
       </div>
       <div class="tip">
-        提示:若打印预览与实际样式不一致,请在弹出的打印窗口中勾选【背景图形】选项。
+        {{ lang.ssPrintBgTip }}
       </div>
     </div>
 
     <div class="btns">
-      <Button class="btn export" type="primary" @click="expPDF()">打印 / 导出 PDF</Button>
-      <Button class="btn close" @click="emit('close')">关闭</Button>
+      <Button class="btn export" type="primary" @click="expPDF()">{{ lang.ssPrintPdf }}</Button>
+      <Button class="btn close" @click="emit('close')">{{ lang.ssClose }}</Button>
     </div>
   </div>
 </template>
@@ -66,6 +66,7 @@ import { ref, useTemplateRef } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useSlidesStore } from '@/store'
 import { print } from '@/utils/print'
+import { lang } from '@/main'
 
 import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
 import Switch from '@/components/Switch.vue'

+ 15 - 14
src/views/Editor/ExportDialog/ExportPPTX.vue

@@ -1,19 +1,19 @@
 <template>
   <div class="export-pptx-dialog">
-    <div class="configs">
+    <div class="configs" :style="{ width: lang.lang === 'en' ? '475px' : '350px' }">
       <div class="row">
-        <div class="title">导出范围:</div>
+        <div class="title">{{ lang.ssExpRange }}</div>
         <RadioGroup
           class="config-item"
           v-model:value="rangeType"
         >
-          <RadioButton style="width: 33.33%;" value="all">全部</RadioButton>
-          <RadioButton style="width: 33.33%;" value="current">当前页</RadioButton>
-          <RadioButton style="width: 33.33%;" value="custom">自定义</RadioButton>
+          <RadioButton style="width: 33.33%;" value="all">{{ lang.ssShowAll }}</RadioButton>
+          <RadioButton style="width: 33.33%;" value="current">{{ lang.ssCurPage }}</RadioButton>
+          <RadioButton style="width: 33.33%;" value="custom">{{ lang.ssCustom }}</RadioButton>
         </RadioGroup>
       </div>
       <div class="row" v-if="rangeType === 'custom'">
-        <div class="title" :data-range="`(${range[0]} ~ ${range[1]})`">自定义范围:</div>
+        <div class="title" :data-range="`(${range[0]} ~ ${range[1]})`">{{ lang.ssCustomRange }}</div>
         <Slider
           class="config-item"
           range
@@ -24,28 +24,28 @@
         />
       </div>
       <div class="row">
-        <div class="title">忽略音频/视频:</div>
+        <div class="title">{{ lang.ssIgnoreMedia }}</div>
         <div class="config-item">
-          <Switch v-model:value="ignoreMedia" v-tooltip="'导出时默认忽略音视频,若您的幻灯片中存在音视频元素,且希望将其导出到PPTX文件中,可选择关闭【忽略音视频】选项,但要注意这将会大幅增加导出用时。'" />
+          <Switch v-model:value="ignoreMedia" v-tooltip="lang.ssIgnoreMediaTip" />
         </div>
       </div>
       <div class="row">
-        <div class="title">覆盖默认母版:</div>
+        <div class="title">{{ lang.ssMastOver }}</div>
         <div class="config-item">
           <Switch v-model:value="masterOverwrite" />
         </div>
       </div>
 
       <div class="tip" v-if="!ignoreMedia">
-        提示:1. 支持导出格式:avi、mp4、mov、wmv、mp3、wav;2. 跨域资源无法导出。
+        {{ lang.ssPptxTip }}
       </div>
     </div>
     <div class="btns">
-      <Button class="btn export" type="primary" @click="exportPPTX(selectedSlides, masterOverwrite, ignoreMedia)">导出 PPTX</Button>
-      <Button class="btn close" @click="emit('close')">关闭</Button>
+      <Button class="btn export" type="primary" @click="exportPPTX(selectedSlides, masterOverwrite, ignoreMedia)">{{ lang.ssExportPptx }}</Button>
+      <Button class="btn close" @click="emit('close')">{{ lang.ssClose }}</Button>
     </div>
 
-    <FullscreenSpin :loading="exporting" tip="正在导出..." />
+    <FullscreenSpin :loading="exporting" :tip="lang.ssExporting" />
   </div>
 </template>
 
@@ -54,6 +54,7 @@ import { computed, ref } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useSlidesStore } from '@/store'
 import useExport from '@/hooks/useExport'
+import { lang } from '@/main'
 
 import FullscreenSpin from '@/components/FullscreenSpin.vue'
 import Switch from '@/components/Switch.vue'
@@ -110,7 +111,7 @@ const selectedSlides = computed(() => {
   }
 
   .title {
-    width: 100px;
+    width: 110px;
     position: relative;
 
     &::after {

+ 10 - 9
src/views/Editor/ExportDialog/ExportSpecificFile.vue

@@ -1,19 +1,19 @@
 <template>
   <div class="export-pptist-dialog">
-    <div class="configs">
+    <div class="configs" :style="{ width: lang.lang === 'en' ? '475px' : '350px' }">
       <div class="row">
-        <div class="title">导出范围:</div>
+        <div class="title">{{ lang.ssExpRange }}</div>
         <RadioGroup
           class="config-item"
           v-model:value="rangeType"
         >
-          <RadioButton style="width: 33.33%;" value="all">全部</RadioButton>
-          <RadioButton style="width: 33.33%;" value="current">当前页</RadioButton>
-          <RadioButton style="width: 33.33%;" value="custom">自定义</RadioButton>
+          <RadioButton style="width: 33.33%;" value="all">{{ lang.ssShowAll }}</RadioButton>
+          <RadioButton style="width: 33.33%;" value="current">{{ lang.ssCurPage }}</RadioButton>
+          <RadioButton style="width: 33.33%;" value="custom">{{ lang.ssCustom }}</RadioButton>
         </RadioGroup>
       </div>
       <div class="row" v-if="rangeType === 'custom'">
-        <div class="title" :data-range="`(${range[0]} ~ ${range[1]})`">自定义范围:</div>
+        <div class="title" :data-range="`(${range[0]} ~ ${range[1]})`">{{ lang.ssCustomRange }}</div>
         <Slider
           class="config-item"
           range
@@ -24,12 +24,12 @@
         />
       </div>
       <div class="tip">
-        提示:.pptist 是本应用的特有文件后缀,支持将该类型的文件导入回应用中。
+        {{ lang.ssPptistTip }}
       </div>
     </div>
     <div class="btns">
-      <Button class="btn export" type="primary" @click="exportSpecificFile(selectedSlides)">导出 .pptist 文件</Button>
-      <Button class="btn close" @click="emit('close')">关闭</Button>
+      <Button class="btn export" type="primary" @click="exportSpecificFile(selectedSlides)">{{ lang.ssExportPptist }}</Button>
+      <Button class="btn close" @click="emit('close')">{{ lang.ssClose }}</Button>
     </div>
   </div>
 </template>
@@ -39,6 +39,7 @@ import { computed, ref } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useSlidesStore } from '@/store'
 import useExport from '@/hooks/useExport'
+import { lang } from '@/main'
 
 import Slider from '@/components/Slider.vue'
 import Button from '@/components/Button.vue'

+ 6 - 5
src/views/Editor/ExportDialog/index.vue

@@ -17,6 +17,7 @@ import { computed } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useMainStore } from '@/store'
 import type { DialogForExportTypes } from '@/types/export'
+import { lang } from '@/main'
 
 import ExportImage from './ExportImage.vue'
 import ExportJSON from './ExportJSON.vue'
@@ -36,11 +37,11 @@ const { dialogForExport } = storeToRefs(mainStore)
 const setDialogForExport = mainStore.setDialogForExport
 
 const tabs: TabItem[] = [
-  { key: 'pptist', label: '导出 pptist 文件' },
-  { key: 'pptx', label: '导出 PPTX' },
-  { key: 'image', label: '导出图片' },
-  { key: 'json', label: '导出 JSON' },
-  { key: 'pdf', label: '打印 / 导出 PDF' },
+  { key: 'pptist', label: lang.ssExpPptist },
+  { key: 'pptx', label: lang.ssExpPptx },
+  { key: 'image', label: lang.ssExpImage },
+  { key: 'json', label: lang.ssExpJson },
+  { key: 'pdf', label: lang.ssPrintPdf },
 ]
 
 const currentDialogComponent = computed<unknown>(() => {

+ 27 - 26
src/views/Editor/MarkupPanel.vue

@@ -3,14 +3,14 @@
     class="notes-panel" 
     :width="300" 
     :height="130" 
-    title="幻灯片类型标注" 
+    :title="lang.ssMarkupTitle" 
     :left="-270" 
     :top="90"
     @close="close()"
   >
     <div class="container">
       <div class="row">
-        <div style="width: 40%;">当前页面类型:</div>
+        <div style="width: 40%;">{{ lang.ssCurPageType }}</div>
         <Select
           style="width: 60%;"
           :value="slideType"
@@ -19,7 +19,7 @@
         />
       </div>
       <div class="row" v-if="handleElement && (handleElement.type === 'text' || (handleElement.type === 'shape' && handleElement.text))">
-        <div style="width: 40%;">当前文本类型:</div>
+        <div style="width: 40%;">{{ lang.ssCurTextType }}</div>
         <Select
           style="width: 60%;"
           :value="textType"
@@ -28,7 +28,7 @@
         />
       </div>
       <div class="row" v-else-if="handleElement && handleElement.type === 'image'">
-        <div style="width: 40%;">当前图片类型:</div>
+        <div style="width: 40%;">{{ lang.ssCurImgType }}</div>
         <Select
           style="width: 60%;"
           :value="imageType"
@@ -36,7 +36,7 @@
           :options="imageTypeOptions"
         />
       </div>
-      <div class="placeholder" v-else>选中图片、文字、带文字的形状,标记类型</div>
+      <div class="placeholder" v-else>{{ lang.ssMarkupHint }}</div>
     </div>
   </MoveablePanel>
 </template>
@@ -46,6 +46,7 @@ import { computed, ref } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useMainStore, useSlidesStore } from '@/store'
 import type { ImageType, SlideType, TextType } from '@/types/slides'
+import { lang } from '@/main'
 
 import MoveablePanel from '@/components/MoveablePanel.vue'
 import Select from '@/components/Select.vue'
@@ -56,33 +57,33 @@ const { currentSlide } = storeToRefs(slidesStore)
 const { handleElement, handleElementId } = storeToRefs(mainStore)
 
 const slideTypeOptions = ref<{ label: string; value: SlideType | '' }[]>([
-  { label: '未标记类型', value: '' },
-  { label: '封面页', value: 'cover' },
-  { label: '目录页', value: 'contents' },
-  { label: '过渡页', value: 'transition' },
-  { label: '内容页', value: 'content' },
-  { label: '结束页', value: 'end' },
+  { label: lang.ssUnmarkedType, value: '' },
+  { label: lang.ssCoverPage, value: 'cover' },
+  { label: lang.ssTocPage, value: 'contents' },
+  { label: lang.ssTransPage, value: 'transition' },
+  { label: lang.ssContentPage, value: 'content' },
+  { label: lang.ssEndPage, value: 'end' },
 ])
 
 const textTypeOptions = ref<{ label: string; value: TextType | '' }[]>([
-  { label: '未标记类型', value: '' },
-  { label: '标题', value: 'title' },
-  { label: '副标题', value: 'subtitle' },
-  { label: '正文', value: 'content' },
-  { label: '列表项目', value: 'item' },
-  { label: '列表项标题', value: 'itemTitle' },
-  { label: '注释', value: 'notes' },
-  { label: '页眉', value: 'header' },
-  { label: '页脚', value: 'footer' },
-  { label: '节编号', value: 'partNumber' },
-  { label: '项目编号', value: 'itemNumber' },
+  { label: lang.ssUnmarkedType, value: '' },
+  { label: lang.ssTxtTitle, value: 'title' },
+  { label: lang.ssTxtSubttl, value: 'subtitle' },
+  { label: lang.ssTxtBody, value: 'content' },
+  { label: lang.ssTxtItem, value: 'item' },
+  { label: lang.ssTxtItemTtl, value: 'itemTitle' },
+  { label: lang.ssTxtNotes, value: 'notes' },
+  { label: lang.ssTxtHeader, value: 'header' },
+  { label: lang.ssTxtFooter, value: 'footer' },
+  { label: lang.ssTxtPartNo, value: 'partNumber' },
+  { label: lang.ssTxtItemNo, value: 'itemNumber' },
 ])
 
 const imageTypeOptions = ref<{ label: string; value: ImageType | '' }[]>([
-  { label: '未标记类型', value: '' },
-  { label: '页面插图', value: 'pageFigure' },
-  { label: '项目插图', value: 'itemFigure' },
-  { label: '背景图', value: 'background' },
+  { label: lang.ssUnmarkedType, value: '' },
+  { label: lang.ssImgPageFig, value: 'pageFigure' },
+  { label: lang.ssImgItemFig, value: 'itemFigure' },
+  { label: lang.ssImgBg, value: 'background' },
 ])
 
 const slideType = computed(() => currentSlide.value?.type || '')

+ 19 - 13
src/views/Editor/NotesPanel.vue

@@ -3,7 +3,7 @@
     class="notes-panel" 
     :width="300" 
     :height="560" 
-    :title="`幻灯片${slideIndex + 1}的批注`" 
+    :title="lang.ssSlideNote.replace(/\*/g, String(slideIndex + 1))" 
     :left="-270" 
     :top="90"
     :minWidth="300"
@@ -25,8 +25,8 @@
               </div>
             </div>
             <div class="btns">
-              <div class="btn reply" @click="replyNoteId = note.id">回复</div>
-              <div class="btn delete" @click.stop="deleteNote(note.id)">删除</div>
+              <div class="btn reply" @click="replyNoteId = note.id">{{ lang.ssReply }}</div>
+              <div class="btn delete" @click.stop="deleteNote(note.id)">{{ lang.ssDelete }}</div>
             </div>
           </div>
           <div class="content">{{ note.content }}</div>
@@ -41,35 +41,35 @@
                   </div>
                 </div>
                 <div class="btns">
-                  <div class="btn delete" @click.stop="deleteReply(note.id, reply.id)">删除</div>
+                  <div class="btn delete" @click.stop="deleteReply(note.id, reply.id)">{{ lang.ssDelete }}</div>
                 </div>
               </div>
               <div class="content">{{ reply.content }}</div>
             </div>
           </div>
           <div class="note-reply" v-if="replyNoteId === note.id">
-            <TextArea :padding="6" v-model:value="replyContent" placeholder="输入回复内容" :rows="1" @enter.prevent="createNoteReply()" />
+            <TextArea :padding="6" v-model:value="replyContent" :placeholder="lang.ssReplyInput" :rows="1" @enter.prevent="createNoteReply()" />
             <div class="reply-btns">
-              <Button class="btn" size="small" @click="replyNoteId = ''">取消</Button>
-              <Button class="btn" size="small" type="primary" @click="createNoteReply()">回复</Button>
+              <Button class="btn" size="small" @click="replyNoteId = ''">{{ lang.ssCancel }}</Button>
+              <Button class="btn" size="small" type="primary" @click="createNoteReply()">{{ lang.ssReply }}</Button>
             </div>
           </div>
         </div>
-        <div class="empty" v-if="!notes.length">本页暂无批注</div>
+        <div class="empty" v-if="!notes.length">{{ lang.ssNoNotes }}</div>
       </div>
       <div class="send">
         <TextArea 
           ref="textAreaRef"
           v-model:value="content"
           :padding="6"
-          :placeholder="`输入批注(为${handleElementId ? '选中元素' : '当前页幻灯片' })`"
+          :placeholder="notePlaceholder"
           :rows="2"
           @focus="replyNoteId = ''; activeNoteId = ''"
           @enter.prevent="createNote()"
         />
         <div class="footer">
-          <IconDelete class="btn icon" v-tooltip="'清空本页批注'" style="flex: 1" @click="clear()" />
-          <Button type="primary" class="btn" style="flex: 12" @click="createNote()">添加批注</Button>
+          <IconDelete class="btn icon" v-tooltip="lang.ssClearNotes" style="flex: 1" @click="clear()" />
+          <Button type="primary" class="btn" style="flex: 12" @click="createNote()">{{ lang.ssAddNote }}</Button>
         </div>
       </div>
     </div>
@@ -86,6 +86,7 @@ import type { Note } from '@/types/slides'
 import MoveablePanel from '@/components/MoveablePanel.vue'
 import TextArea from '@/components/TextArea.vue'
 import Button from '@/components/Button.vue'
+import { lang } from '@/main'
 
 const slidesStore = useSlidesStore()
 const mainStore = useMainStore()
@@ -100,6 +101,11 @@ const replyNoteId = ref('')
 const textAreaRef = useTemplateRef<InstanceType<typeof TextArea>>('textAreaRef')
 const notesRef = useTemplateRef<HTMLElement>('notesRef')
 
+const notePlaceholder = computed(() => {
+  const target = handleElementId.value ? lang.ssSelEl : lang.ssCurSlide
+  return lang.lang == 'en' ? 'Add a note for this slide...' :lang.ssNoteInput.replace(/\*/g, target)
+})
+
 watch(slideIndex, () => {
   activeNoteId.value = ''
   replyNoteId.value = ''
@@ -121,7 +127,7 @@ const createNote = () => {
     id: nanoid(),
     content: content.value,
     time: new Date().getTime(),
-    user: '测试用户',
+    user: lang.ssTestUser,
   }
   if (handleElementId.value) newNote.elId = handleElementId.value
 
@@ -153,7 +159,7 @@ const createNoteReply = () => {
       id: nanoid(),
       content: replyContent.value,
       time: new Date().getTime(),
-      user: '测试用户',
+      user: lang.ssTestUser,
     },
   ]
   const newNote: Note = {

+ 3 - 2
src/views/Editor/Remark/Editor.vue

@@ -38,6 +38,7 @@ import { initProsemirrorEditor, createDocument } from '@/utils/prosemirror'
 import { addMark, autoSelectAll, getTextAttrs, type TextAttrs } from '@/utils/prosemirror/utils'
 import { toggleList } from '@/utils/prosemirror/commands/toggleList'
 import tippy, { type Instance } from 'tippy.js'
+import { lang } from '@/main'
 
 import ColorPicker from '@/components/ColorPicker/index.vue'
 import Popover from '@/components/Popover.vue'
@@ -177,7 +178,7 @@ onMounted(() => {
       input: handleInput,
     },
   }, {
-    placeholder: '点击输入演讲者备注',
+    placeholder: lang.ssSpeakerNotePh,
   })
 
   menuInstance.value = tippy(editorViewRef.value!, {
@@ -210,7 +211,7 @@ onUnmounted(() => {
     font-size: 12px;
     overflow: auto;
     padding: 8px;
-    line-height: 1.5;
+    line-height: 1.15;
 
     & > p[data-placeholder]::before {
       content: attr(data-placeholder);

+ 10 - 9
src/views/Editor/SearchPanel.vue

@@ -13,24 +13,24 @@
     />
 
     <div class="content" :class="type" @mousedown.stop>
-      <Input class="input" v-model:value="searchWord" placeholder="输入查找内容" @enter="searchNext()" ref="searchInpRef">
+      <Input class="input" v-model:value="searchWord" :placeholder="lang.ssFindInput" @enter="searchNext()" ref="searchInpRef">
         <template #suffix>
           <span class="count">{{searchIndex + 1}}/{{searchResults.length}}</span>
           <Divider type="vertical" />
           <span class="ignore-case"
             :class="{ 'active': modifiers === 'g' }"
-            v-tooltip="'忽略大小写'"
+            v-tooltip="lang.ssIgnoreCase"
             @click="toggleModifiers()"
           >Aa</span>
           <Divider type="vertical" />
-          <IconLeft class="next-btn left" @click="searchPrev()" v-tooltip="'上一个'" />
-          <IconRight class="next-btn right" @click="searchNext()" v-tooltip="'下一个'" />
+          <IconLeft class="next-btn left" @click="searchPrev()" v-tooltip="lang.ssPrevOne" />
+          <IconRight class="next-btn right" @click="searchNext()" v-tooltip="lang.ssNextOne" />
         </template>
       </Input>
-      <Input class="input" v-model:value="replaceWord" placeholder="输入替换内容" @enter="replace()" v-if="type === 'replace'"></Input>
+      <Input class="input" v-model:value="replaceWord" :placeholder="lang.ssReplInput" @enter="replace()" v-if="type === 'replace'"></Input>
       <div class="footer" v-if="type === 'replace'">
-        <Button :disabled="!searchWord" style="margin-left: 5px;" @click="replace()">替换</Button>
-        <Button :disabled="!searchWord" type="primary" style="margin-left: 5px;" @click="replaceAll()">全部替换</Button>
+        <Button :disabled="!searchWord" style="margin-left: 5px;" @click="replace()">{{ lang.ssReplace }}</Button>
+        <Button :disabled="!searchWord" type="primary" style="margin-left: 5px;" @click="replaceAll()">{{ lang.ssReplaceAll }}</Button>
       </div>
     </div>
   </MoveablePanel>
@@ -45,6 +45,7 @@ import Tabs from '@/components/Tabs.vue'
 import Divider from '@/components/Divider.vue'
 import Input from '@/components/Input.vue'
 import Button from '@/components/Button.vue'
+import { lang } from '@/main'
 
 type TypeKey = 'search' | 'replace'
 interface TabItem {
@@ -69,8 +70,8 @@ const {
 
 const type = ref<TypeKey>('search')
 const tabs: TabItem[] = [
-  { key: 'search', label: '查找' },
-  { key: 'replace', label: '替换' },
+  { key: 'search', label: lang.ssFindTab },
+  { key: 'replace', label: lang.ssReplTab },
 ]
 
 const close = () => {

+ 16 - 9
src/views/Editor/SelectPanel.vue

@@ -3,15 +3,15 @@
     class="select-panel" 
     :width="200" 
     :height="360" 
-    :title="`选择(${activeElementIdList.length}/${currentSlide.elements.length})`" 
+    :title="selectTitle"
     :left="-270" 
     :top="90"
     @close="close()"
   >
     <div class="handler" v-if="elements.length">
       <div class="btns">
-        <Button size="small" style="margin-right: 5px;" @click="showAllElements()">全部显示</Button>
-        <Button size="small" @click="hideAllElements()">全部隐藏</Button>
+        <Button size="small" style="margin-right: 5px;" @click="showAllElements()">{{ lang.ssShowAll }}</Button>
+        <Button size="small" @click="hideAllElements()">{{ lang.ssHideAll }}</Button>
       </div>
       <div class="icon-btns" v-if="handleElement">
         <IconDown class="icon-btn" @click="orderElement(handleElement!, ElementOrderCommands.UP)" />
@@ -21,7 +21,7 @@
     <div class="element-list">
       <template v-for="item in elements" :key="item.id">
         <div class="group-els" v-if="item.type === 'group'">
-          <div class="group-title">组合</div>
+          <div class="group-title">{{ lang.ssGroup }}</div>
           <div 
             class="item" 
             :class="{
@@ -35,14 +35,14 @@
           >
             <input 
               :id="`select-panel-input-${groupItem.id}`" 
-              :value="groupItem.name || ELEMENT_TYPE_ZH[groupItem.type]" 
+              :value="groupItem.name || getElementTypeZh()[groupItem.type]" 
               class="input" 
               type="text" 
               v-if="editingElId === groupItem.id" 
               @blur="$event => saveElementName($event, groupItem.id)"
               @keydown.enter="$event => saveElementName($event, groupItem.id)"
             >
-            <div v-else class="name">{{groupItem.name || ELEMENT_TYPE_ZH[groupItem.type]}}</div>
+            <div v-else class="name">{{ groupItem.name || getElementTypeZh()[groupItem.type] }}</div>
             <div class="icons">
               <IconPreviewClose style="font-size: 17px;" @click.stop="toggleHideElement(groupItem.id)" v-if="hiddenElementIdList.includes(groupItem.id)" />
               <IconPreviewOpen style="font-size: 17px;" @click.stop="toggleHideElement(groupItem.id)" v-else />
@@ -58,14 +58,14 @@
         >
           <input 
             :id="`select-panel-input-${item.id}`" 
-            :value="item.name || ELEMENT_TYPE_ZH[item.type]" 
+            :value="item.name || getElementTypeZh()[item.type]" 
             class="input" 
             type="text" 
             v-if="editingElId === item.id" 
             @blur="$event => saveElementName($event, item.id)"
             @keydown.enter="$event => saveElementName($event, item.id)"
           >
-          <div v-else class="name">{{item.name || ELEMENT_TYPE_ZH[item.type]}}</div>
+          <div v-else class="name">{{ item.name || getElementTypeZh()[item.type] }}</div>
           <div class="icons">
             <IconPreviewClose style="font-size: 17px;" @click.stop="toggleHideElement(item.id)" v-if="hiddenElementIdList.includes(item.id)" />
             <IconPreviewOpen style="font-size: 17px;" @click.stop="toggleHideElement(item.id)" v-else />
@@ -81,11 +81,12 @@ import { computed, nextTick, ref } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useSlidesStore, useMainStore } from '@/store'
 import type { PPTElement } from '@/types/slides'
-import { ELEMENT_TYPE_ZH } from '@/configs/element'
+import { getElementTypeZh } from '@/configs/element'
 import useOrderElement from '@/hooks/useOrderElement'
 import useHideElement from '@/hooks/useHideElement'
 import useSelectElement from '@/hooks/useSelectElement'
 import { ElementOrderCommands } from '@/types/edit'
+import { lang } from '@/main'
 
 import MoveablePanel from '@/components/MoveablePanel.vue'
 import Button from '@/components/Button.vue'
@@ -99,6 +100,12 @@ const { orderElement } = useOrderElement()
 const { selectElement } = useSelectElement()
 const { toggleHideElement, showAllElements, hideAllElements } = useHideElement()
 
+const selectTitle = computed(() => {
+  const values = [activeElementIdList.value.length, currentSlide.value.elements.length]
+  let i = 0
+  return lang.ssSelectCnt.replace(/\*/g, () => String(values[i++] ?? ''))
+})
+
 interface GroupElements {
   type: 'group'
   id: string

+ 555 - 0
src/views/Editor/Thumbnails/index2.vue

@@ -0,0 +1,555 @@
+<template>
+  <div 
+    class="thumbnails"
+    @mousedown="() => setThumbnailsFocus(true)"
+    v-click-outside="() => setThumbnailsFocus(false)"
+    v-contextmenu="contextmenusThumbnails"
+  >
+    <div class="add-slide">
+      <div class="btn" @click="createSlide()"><IconPlus class="icon" /></div>
+      <!-- 添加幻灯片 <Popover trigger="click" placement="bottom-start" v-model:value="presetLayoutPopoverVisible" center>
+        <template #content>
+          <Templates 
+            @select="slide => { createSlideByTemplate(slide); presetLayoutPopoverVisible = false }"
+            @selectAll="slides => { insertAllTemplates(slides); presetLayoutPopoverVisible = false }"
+          />
+        </template>
+        <div class="select-btn"><IconDown /></div>
+      </Popover> -->
+    </div>
+
+    <Draggable 
+      class="thumbnail-list"
+      ref="thumbnailsRef"
+      :modelValue="slides"
+      :animation="200"
+      :scroll="true"
+      :scrollSensitivity="50"
+      :disabled="editingSectionId"
+      @end="handleDragEnd"
+      itemKey="id"
+    >
+      <template #item="{ element, index }">
+        <div class="thumbnail-container">
+          <div class="section-title"
+            :data-section-id="element?.sectionTag?.id || ''"
+            v-if="element.sectionTag || (hasSection && index === 0)" 
+            v-contextmenu="contextmenusSection"
+          >
+            <input 
+              :id="`section-title-input-${element?.sectionTag?.id || 'default'}`" 
+              type="text"
+              :value="element?.sectionTag?.title || ''"
+              :placeholder="lang.ssSectName"
+              @blur="$event => saveSection($event)"
+              @keydown.enter.stop="$event => saveSection($event)"
+              v-if="editingSectionId === element?.sectionTag?.id || (index === 0 && editingSectionId === 'default')"
+            >
+            <span class="text" v-else>
+              <div class="text-content">{{ element?.sectionTag ? (element?.sectionTag?.title || lang.ssUntitledSec) : lang.ssDefSec }}</div>
+            </span>
+          </div>
+          <div
+            class="thumbnail-item"
+            :class="{
+              'active': slideIndex === index,
+              'selected': selectedSlidesIndex.includes(index),
+            }"
+            @mousedown="$event => handleClickSlideThumbnail($event, index)"
+            @dblclick="enterScreening()"
+            v-contextmenu="contextmenusThumbnailItem"
+          >
+            <div class="label" :class="{ 'offset-left': index >= 99 }">{{ fillDigit(index + 1, 2) }}</div>
+            <ThumbnailSlide class="thumbnail" :slide="element" :size="120" :visible="index < slidesLoadLimit" />
+  
+            <div class="note-flag" v-if="element.notes && element.notes.length" @click="openNotesPanel()">{{ element.notes.length }}</div>
+          </div>
+        </div>
+      </template>
+    </Draggable>
+
+    <div class="page-number">{{ lang.ssSlidePage }} {{slideIndex + 1}} {{ lang.lang == 'en' ? 'of' : '/' }} {{slides.length}}</div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, nextTick, ref, watch, useTemplateRef } from 'vue'
+import { storeToRefs } from 'pinia'
+import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store'
+import { fillDigit } from '@/utils/common'
+import { isElementInViewport } from '@/utils/element'
+import type { ContextmenuItem } from '@/components/Contextmenu/types'
+import useSlideHandler from '@/hooks/useSlideHandler'
+import useSectionHandler from '@/hooks/useSectionHandler'
+import useScreening from '@/hooks/useScreening'
+import useLoadSlides from '@/hooks/useLoadSlides'
+import useAddSlidesOrElements from '@/hooks/useAddSlidesOrElements'
+import type { Slide } from '@/types/slides'
+import { lang } from '@/main'
+
+import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
+import Templates from './Templates.vue'
+import Popover from '@/components/Popover.vue'
+import Draggable from 'vuedraggable'
+
+const mainStore = useMainStore()
+const slidesStore = useSlidesStore()
+const keyboardStore = useKeyboardStore()
+const { selectedSlidesIndex: _selectedSlidesIndex, thumbnailsFocus } = storeToRefs(mainStore)
+const { slides, slideIndex, currentSlide } = storeToRefs(slidesStore)
+const { ctrlKeyState, shiftKeyState } = storeToRefs(keyboardStore)
+
+const { slidesLoadLimit } = useLoadSlides()
+
+const selectedSlidesIndex = computed(() => [..._selectedSlidesIndex.value, slideIndex.value])
+
+const presetLayoutPopoverVisible = ref(false)
+
+const hasSection = computed(() => {
+  return slides.value.some(item => item.sectionTag)
+})
+
+const { addSlidesFromData } = useAddSlidesOrElements()
+
+const {
+  copySlide,
+  pasteSlide,
+  createSlide,
+  createSlideByTemplate,
+  copyAndPasteSlide,
+  deleteSlide,
+  cutSlide,
+  selectAllSlide,
+  sortSlides,
+  isEmptySlide,
+} = useSlideHandler()
+
+const {
+  createSection,
+  removeSection,
+  removeAllSection,
+  removeSectionSlides,
+  updateSectionTitle,
+} = useSectionHandler()
+
+// 页面被切换时
+const thumbnailsRef = useTemplateRef<InstanceType<typeof Draggable>>('thumbnailsRef')
+watch(() => slideIndex.value, () => {
+
+  // 清除多选状态的幻灯片
+  if (selectedSlidesIndex.value.length) {
+    mainStore.updateSelectedSlidesIndex([])
+  }
+
+  // 检查当前页缩略图是否在可视范围,不在的话需要滚动到对应的位置
+  nextTick(() => {
+    const activeThumbnailRef: HTMLElement = thumbnailsRef.value?.$el?.querySelector('.thumbnail-item.active')
+    if (thumbnailsRef.value && activeThumbnailRef && !isElementInViewport(activeThumbnailRef, thumbnailsRef.value.$el)) {
+      setTimeout(() => {
+        activeThumbnailRef.scrollIntoView({ behavior: 'smooth' })
+      }, 100)
+    }
+  })
+}, { immediate: true })
+
+// 切换页面
+const changeSlideIndex = (index: number) => {
+  mainStore.setActiveElementIdList([])
+
+  if (slideIndex.value === index) return
+  slidesStore.updateSlideIndex(index)
+}
+
+// 点击缩略图
+const handleClickSlideThumbnail = (e: MouseEvent, index: number) => {
+  if (editingSectionId.value) return
+
+  const isMultiSelected = selectedSlidesIndex.value.length > 1
+
+  if (isMultiSelected && selectedSlidesIndex.value.includes(index) && e.button !== 0) return
+
+  // 按住Ctrl键,点选幻灯片,再次点击已选中的页面则取消选中
+  // 如果被取消选中的页面刚好是当前激活页面,则需要从其他被选中的页面中选择第一个作为当前激活页面
+  if (ctrlKeyState.value) {
+    if (slideIndex.value === index) {
+      if (!isMultiSelected) return
+
+      const newSelectedSlidesIndex = selectedSlidesIndex.value.filter(item => item !== index)
+      mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
+      changeSlideIndex(selectedSlidesIndex.value[0])
+    }
+    else {
+      if (selectedSlidesIndex.value.includes(index)) {
+        const newSelectedSlidesIndex = selectedSlidesIndex.value.filter(item => item !== index)
+        mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
+      }
+      else {
+        const newSelectedSlidesIndex = [...selectedSlidesIndex.value, index]
+        mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
+      }
+    }
+  }
+  // 按住Shift键,选择范围内的全部幻灯片
+  else if (shiftKeyState.value) {
+    if (slideIndex.value === index && !isMultiSelected) return
+
+    let minIndex = Math.min(...selectedSlidesIndex.value)
+    let maxIndex = index
+
+    if (index < minIndex) {
+      maxIndex = Math.max(...selectedSlidesIndex.value)
+      minIndex = index
+    }
+
+    const newSelectedSlidesIndex = []
+    for (let i = minIndex; i <= maxIndex; i++) newSelectedSlidesIndex.push(i)
+    mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
+  }
+  // 正常切换页面
+  else {
+    mainStore.updateSelectedSlidesIndex([])
+    changeSlideIndex(index)
+  }
+}
+
+// 设置缩略图工具栏聚焦状态(只有聚焦状态下,该部分的快捷键才能生效)
+const setThumbnailsFocus = (focus: boolean) => {
+  if (thumbnailsFocus.value === focus) return
+  mainStore.setThumbnailsFocus(focus)
+
+  if (!focus) mainStore.updateSelectedSlidesIndex([])
+}
+
+// 拖拽调整顺序后进行数据的同步
+const handleDragEnd = (eventData: { newIndex: number; oldIndex: number }) => {
+  const { newIndex, oldIndex } = eventData
+  if (newIndex === undefined || oldIndex === undefined || newIndex === oldIndex) return
+  sortSlides(newIndex, oldIndex)
+}
+
+// 打开批注面板
+const openNotesPanel = () => {
+  mainStore.setNotesPanelState(true)
+}
+
+const editingSectionId = ref('')
+
+const editSection = (id: string) => {
+  mainStore.setDisableHotkeysState(true)
+  editingSectionId.value = id || 'default'
+
+  nextTick(() => {
+    const inputRef = document.querySelector(`#section-title-input-${id || 'default'}`) as HTMLInputElement
+    inputRef.focus()
+  })
+}
+
+const saveSection = (e: FocusEvent | KeyboardEvent) => {
+  const title = (e.target as HTMLInputElement).value
+  updateSectionTitle(editingSectionId.value, title)
+
+  editingSectionId.value = ''
+  mainStore.setDisableHotkeysState(false)
+}
+
+const insertAllTemplates = (slides: Slide[]) => {
+  if (isEmptySlide.value) slidesStore.setSlides(slides)
+  else addSlidesFromData(slides)
+}
+
+const contextmenusSection = (el: HTMLElement): ContextmenuItem[] => {
+  const sectionId = el.dataset.sectionId!
+
+  return [
+    {
+      text: lang.ssDelSect,
+      handler: () => removeSection(sectionId),
+    },
+    {
+      text: lang.ssDelSectSlides,
+      handler: () => {
+        mainStore.setActiveElementIdList([])
+        removeSectionSlides(sectionId)
+      },
+    },
+    {
+      text: lang.ssDelAllSect,
+      handler: removeAllSection,
+    },
+    {
+      text: lang.ssRenSect,
+      handler: () => editSection(sectionId),
+    },
+  ]
+}
+
+const { enterScreening, enterScreeningFromStart } = useScreening()
+
+const contextmenusThumbnails = (): ContextmenuItem[] => {
+  return [
+    {
+      text: lang.ssPaste,
+      subText: 'Ctrl + V',
+      handler: pasteSlide,
+    },
+    {
+      text: lang.ssSelectAll,
+      subText: 'Ctrl + A',
+      handler: selectAllSlide,
+    },
+    {
+      text: lang.ssNewPage,
+      subText: 'Enter',
+      handler: createSlide,
+    },
+    {
+      text: lang.ssSlideShow,
+      subText: 'F5',
+      handler: enterScreeningFromStart,
+    },
+  ]
+}
+
+const contextmenusThumbnailItem = (): ContextmenuItem[] => {
+  return [
+    {
+      text: lang.ssCut,
+      subText: 'Ctrl + X',
+      handler: cutSlide,
+    },
+    {
+      text: lang.ssCopy,
+      subText: 'Ctrl + C',
+      handler: copySlide,
+    },
+    {
+      text: lang.ssPaste,
+      subText: 'Ctrl + V',
+      handler: pasteSlide,
+    },
+    {
+      text: lang.ssSelectAll,
+      subText: 'Ctrl + A',
+      handler: selectAllSlide,
+    },
+    { divider: true },
+    {
+      text: lang.ssNewPage,
+      subText: 'Enter',
+      handler: createSlide,
+    },
+    {
+      text: lang.ssDupPage,
+      subText: 'Ctrl + D',
+      handler: copyAndPasteSlide,
+    },
+    {
+      text: lang.ssDelPage,
+      subText: 'Delete',
+      handler: () => deleteSlide(),
+    },
+    {
+      text: lang.ssAddSect,
+      handler: createSection,
+      disable: !!currentSlide.value.sectionTag,
+    },
+    { divider: true },
+    {
+      text: lang.ssPlayFromCur,
+      subText: 'Shift + F5',
+      handler: enterScreening,
+    },
+  ]
+}
+</script>
+
+<style lang="scss" scoped>
+.thumbnails {
+  border-right: solid 1px $borderColor;
+  background-color: #fff;
+  display: flex;
+  // flex-direction: column;
+  user-select: none;
+  width: 100%;
+  position: relative;
+}
+.add-slide {
+  height: 100%;
+  width: 40px;
+  font-size: 12px;
+  display: flex;
+  flex-shrink: 0;
+  border-right: 1px solid $borderColor;
+  cursor: pointer;
+
+  .btn {
+    flex: 1;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    writing-mode: vertical-rl;
+    text-orientation: upright;
+    letter-spacing: 4px;
+
+    &:hover {
+      background-color: $lightGray;
+    }
+  }
+  .select-btn {
+    width: 30px;
+    height: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    border-left: 1px solid $borderColor;
+
+    &:hover {
+      background-color: $lightGray;
+    }
+  }
+
+  .icon {
+    margin-bottom: 5px;
+    font-size: 14px;
+  }
+}
+.thumbnail-list {
+  padding: 0 10px;
+  flex: 1;
+  overflow: auto;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+.thumbnail-item {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  padding: 5px 0;
+  position: relative;
+
+  .thumbnail {
+    border-radius: $borderRadius;
+    outline: 2px solid rgba($color: $themeColor, $alpha: .15);
+  }
+
+  &.active {
+    .label {
+      color: $themeColor;
+    }
+    .thumbnail {
+      outline-color: $themeColor;
+    }
+  }
+  &.selected {
+    .thumbnail {
+      outline-color: $themeColor;
+    }
+    .note-flag {
+      background-color: $themeColor;
+
+      &::after {
+        border-top-color: $themeColor;
+      }
+    }
+  }
+
+  .note-flag {
+    width: 16px;
+    height: 12px;
+    border-radius: 1px;
+    position: absolute;
+    left: 8px;
+    top: 13px;
+    font-size: 8px;
+    background-color: rgba($color: $themeColor, $alpha: .75);
+    color: #fff;
+    text-align: center;
+    line-height: 12px;
+    cursor: pointer;
+
+    &::after {
+      content: '';
+      width: 0;
+      height: 0;
+      position: absolute;
+      top: 10px;
+      left: 4px;
+      border: 4px solid transparent;
+      border-top-color: rgba($color: $themeColor, $alpha: .75);
+    }
+  }
+}
+.label {
+  font-size: 12px;
+  color: #999;
+  width: 20px;
+  cursor: grab;
+
+  &.offset-left {
+    position: relative;
+    left: -4px;
+  }
+
+  &:active {
+    cursor: grabbing;
+  }
+}
+.page-number {
+  /* height: 100%; */
+  font-size: 12px;
+  /* border-top: 1px solid #e5e7eb; */
+  // line-height: 40px;
+  text-align: center;
+  color: #666;
+  position: absolute;
+  bottom: 10px;
+  left: 50%;
+  transform: translateX(-50%);
+}
+.section-title {
+  height: 26px;
+  font-size: 12px;
+  padding: 6px 8px 2px 18px;
+  color: #555;
+
+  &.contextmenu-active {
+    color: $themeColor;
+
+    .text::before {
+      border-bottom-color: $themeColor;
+      border-right-color: $themeColor;
+    }
+  }
+
+  .text {
+    display: flex;
+    align-items: center;
+    position: relative;
+
+    &::before {
+      content: '';
+      width: 0;
+      height: 0;
+      border-top: 3px solid transparent;
+      border-left: 3px solid transparent;
+      border-bottom: 3px solid #555;
+      border-right: 3px solid #555;
+      margin-right: 5px;
+    }
+
+    .text-content {
+      display: inline-block;
+      @include ellipsis-oneline();
+    }
+  }
+
+  input {
+    width: 100%;
+    border: 0;
+    outline: 0;
+    padding: 0;
+    font-size: 12px;
+  }
+}
+</style>

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

@@ -131,7 +131,7 @@ import {
   ANIMATION_DEFAULT_TRIGGER,
   ANIMATION_CLASS_PREFIX,
 } from '@/configs/animation'
-import { ELEMENT_TYPE_ZH } from '@/configs/element'
+import { getElementTypeZh } from '@/configs/element'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
 
 import Tabs from '@/components/Tabs.vue'
@@ -197,7 +197,7 @@ const animationSequence = computed(() => {
       const el = currentSlide.value.elements.find(el => el.id === animation.elId)
       if (!el) continue
 
-      const elType = ELEMENT_TYPE_ZH[el.type]
+      const elType = getElementTypeZh()[el.type]
       const animationEffect = animationEffects[animation.effect]
       animationSequence.push({
         ...animation,

+ 4 - 3
src/views/Editor/Toolbar/ElementStylePanel/AudioStylePanel.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="audio-style-panel">
     <div class="row">
-      <div style="width: 40%;">图标颜色:</div>
+      <div style="width: 40%;">{{ lang.ssIconColor }}</div>
       <Popover trigger="click" style="width: 60%;">
         <template #content>
           <ColorPicker
@@ -14,7 +14,7 @@
     </div>
 
     <div class="row switch-row">
-      <div style="width: 40%;">自动播放:</div>
+      <div style="width: 40%;">{{ lang.ssAutoplay }}</div>
       <div class="switch-wrapper" style="width: 60%;">
         <Switch 
           :value="handleAudioElement.autoplay" 
@@ -24,7 +24,7 @@
     </div>
 
     <div class="row switch-row">
-      <div style="width: 40%;">循环播放:</div>
+      <div style="width: 40%;">{{ lang.ssLoopPlay }}</div>
       <div class="switch-wrapper" style="width: 60%;">
         <Switch 
           :value="handleAudioElement.loop" 
@@ -41,6 +41,7 @@ import { storeToRefs } from 'pinia'
 import { useMainStore, useSlidesStore } from '@/store'
 import type { PPTAudioElement } from '@/types/slides'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
+import { lang } from '@/main'
 
 import ColorButton from '@/components/ColorButton.vue'
 import ColorPicker from '@/components/ColorPicker/index.vue'

+ 10 - 8
src/views/Editor/Toolbar/ElementStylePanel/ChartStylePanel/ChartDataEditor.vue

@@ -68,7 +68,7 @@
 
     <div class="btns">
       <div class="left">
-        图表类型:{{ CHART_TYPE_MAP[chartType] }}
+        {{ lang.ssChartType }}{{ CHART_TYPE_MAP[chartType] }}
         <Popover trigger="click" placement="top" v-model:value="chartTypeSelectVisible">
           <template #content>
             <PopoverMenuItem
@@ -78,13 +78,13 @@
               @click="chartType = item; chartTypeSelectVisible = false"
             >{{CHART_TYPE_MAP[item]}}</PopoverMenuItem>
           </template>
-          <span class="change">点击更换</span>
+          <span class="change">{{ lang.ssClickChange }}</span>
         </Popover>
       </div>
       <div class="right">
-        <Button class="btn" @click="closeEditor()">取消</Button>
-        <Button class="btn" @click="clear()">清空数据</Button>
-        <Button type="primary" class="btn" @click="getTableData()">确认</Button>
+        <Button class="btn" @click="closeEditor()">{{ lang.ssCancel }}</Button>
+        <Button class="btn" @click="clear()">{{ lang.ssClearData }}</Button>
+        <Button type="primary" class="btn" @click="getTableData()">{{ lang.ssApply }}</Button>
       </div>
     </div>
   </div>
@@ -94,8 +94,9 @@
 import { computed, onMounted, onUnmounted, ref } from 'vue'
 import type { ChartData, ChartType } from '@/types/slides'
 import { KEYS } from '@/configs/hotkey'
-import { CHART_TYPE_MAP } from '@/configs/chart'
+import { getChartTypeMap } from '@/configs/chart'
 import { pasteCustomClipboardString, pasteExcelClipboardString, pasteHTMLTableClipboardString } from '@/utils/clipboard'
+import { lang } from '@/main'
 import Button from '@/components/Button.vue'
 import Popover from '@/components/Popover.vue'
 import PopoverMenuItem from '@/components/PopoverMenuItem.vue'
@@ -123,6 +124,7 @@ const selectedRange = ref([0, 0])
 const tempRangeSize = ref({ width: 0, height: 0 })
 const focusCell = ref<[number, number] | null>(null)
 const chartType = ref<ChartType>('bar')
+const CHART_TYPE_MAP = getChartTypeMap()
 
 // 当前选区的边框线条位置
 const rangeLines = computed(() => {
@@ -206,13 +208,13 @@ const getTableData = () => {
 
   // 第一行为系列名,第一列为项目名,实际数据从第二行第二列开始
   for (let rowIndex = 1; rowIndex < row; rowIndex++) {
-    let labelsItem = `类别${rowIndex}`
+    let labelsItem = lang.ssChartCat.replace(/\*/g, String(rowIndex))
     const labelInputRef = document.querySelector(`#cell-${rowIndex}-0`) as HTMLInputElement
     if (labelInputRef && labelInputRef.value) labelsItem = labelInputRef.value
     labels.push(labelsItem)
   }
   for (let colIndex = 1; colIndex < col; colIndex++) {
-    let legendsItem = `系列${colIndex}`
+    let legendsItem = lang.ssChartSer.replace(/\*/g, String(colIndex))
     const labelInputRef = document.querySelector(`#cell-0-${colIndex}`) as HTMLInputElement
     if (labelInputRef && labelInputRef.value) legendsItem = labelInputRef.value
     legends.push(legendsItem)

+ 6 - 5
src/views/Editor/Toolbar/ElementStylePanel/ChartStylePanel/ThemeColorsSetting.vue

@@ -1,10 +1,10 @@
 <template>
   <div class="theme-colors-setting">
-    <div class="title">图表主题配色</div>
+    <div class="title">{{ lang.ssChartThemeColors }}</div>
 
     <div class="list">
       <div class="row" v-for="(item, index) in themeColors" :key="index">
-        <div class="label" style="width: 40%;">主题配色{{ index + 1 }}</div>
+        <div class="label" style="width: 40%;">{{ lang.ssThemeColorNo.replace(/\*/g, String(index + 1)) }}</div>
         <Popover trigger="click" style="width: 60%;">
           <template #content>
             <ColorPicker
@@ -14,7 +14,7 @@
           </template>
           <div class="color-btn-wrap" style="width: 100%;">
             <ColorButton :color="item" />
-            <div class="delete-color-btn" v-tooltip="'删除'" @click.stop="deleteThemeColor(index)" v-if="index !== 0"><IconCloseSmall /></div>
+            <div class="delete-color-btn" v-tooltip="lang.ssDelete" @click.stop="deleteThemeColor(index)" v-if="index !== 0"><IconCloseSmall /></div>
           </div>
         </Popover>
       </div>
@@ -23,16 +23,17 @@
         :disabled="themeColors.length >= 10"
         @click="addThemeColor()"
       >
-        <IconPlus class="btn-icon" /> 添加主题色
+        <IconPlus class="btn-icon" /> {{ lang.ssAddThemeColor }}
       </Button>
     </div>
 
-    <Button class="btn" type="primary" @click="setThemeColors()">确认</Button>
+    <Button class="btn" type="primary" @click="setThemeColors()">{{ lang.ssConfirm }}</Button>
   </div>
 </template>
 
 <script lang="ts" setup>
 import { ref, onMounted } from 'vue'
+import { lang } from '@/main'
 import Popover from '@/components/Popover.vue'
 import ColorPicker from '@/components/ColorPicker/index.vue'
 import ColorButton from '@/components/ColorButton.vue'

+ 11 - 10
src/views/Editor/Toolbar/ElementStylePanel/ChartStylePanel/index.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="chart-style-panel">
     <Button class="full-width-btn" @click="chartDataEditorVisible = true">
-      <IconEdit class="btn-icon" /> 编辑图表
+      <IconEdit class="btn-icon" /> {{ lang.ssEditChart }}
     </Button>
 
     <Divider />
@@ -12,20 +12,20 @@
           @update:value="value => updateOptions({ stack: value })" 
           :value="stack"
           style="flex: 2;"
-        >堆叠样式</Checkbox>
+        >{{ lang.ssStackStyle }}</Checkbox>
         <Checkbox 
           v-if="handleChartElement.chartType === 'line'"
           @update:value="value => updateOptions({ lineSmooth: value })" 
           :value="lineSmooth"
           style="flex: 3;"
-        >使用平滑曲线</Checkbox>
+        >{{ lang.ssSmoothLine }}</Checkbox>
       </div>
   
       <Divider />
     </template>
 
     <div class="row">
-      <div style="width: 40%;">背景填充:</div>
+      <div style="width: 40%;">{{ lang.ssBgFill }}</div>
       <Popover trigger="click" style="width: 60%;">
         <template #content>
           <ColorPicker
@@ -37,7 +37,7 @@
       </Popover>
     </div>
     <div class="row">
-      <div style="width: 40%;">坐标与文字:</div>
+      <div style="width: 40%;">{{ lang.ssAxisText }}</div>
       <Popover trigger="click" style="width: 60%;">
         <template #content>
           <ColorPicker
@@ -49,7 +49,7 @@
       </Popover>
     </div>
     <div class="row">
-      <div style="width: 40%;">网格颜色:</div>
+      <div style="width: 40%;">{{ lang.ssGridColor }}</div>
       <Popover trigger="click" style="width: 60%;">
         <template #content>
           <ColorPicker
@@ -62,11 +62,11 @@
     </div>
 
     <div class="row">
-      <div style="width: 40%;">主题配色:</div>
+      <div style="width: 40%;">{{ lang.ssChartTheme }}</div>
       <Popover trigger="click" v-model:value="themesVisible" style="width: 60%;">
         <template #content>
           <div class="themes">
-            <div class="label">预置图表主题:</div>
+            <div class="label">{{ lang.ssPresetChartTheme }}</div>
             <div class="preset-themes">
               <div class="preset-theme" v-for="(item, index) in CHART_PRESET_THEMES" :key="index" @click="setThemeColors(item)">
                 <div 
@@ -77,7 +77,7 @@
                 ></div>
               </div>
             </div>
-            <div class="label">幻灯片主题:</div>
+            <div class="label">{{ lang.ssSlideTheme }}</div>
             <div class="preset-themes" :style="{ marginBottom: '-10px' }">
               <div class="preset-theme" @click="setThemeColors(theme.themeColors)">
                 <div 
@@ -89,7 +89,7 @@
               </div>
             </div>
             <Divider :margin="10" />
-            <Button class="full-width-btn" @click="themesVisible = false; themeColorsSettingVisible = true">自定义配色</Button>
+            <Button class="full-width-btn" @click="themesVisible = false; themeColorsSettingVisible = true">{{ lang.ssCustomColors }}</Button>
           </div>
         </template>
         <ColorListButton :colors="themeColors" />
@@ -130,6 +130,7 @@ import type { ChartData, ChartOptions, ChartType, PPTChartElement } from '@/type
 import emitter, { EmitterEvents } from '@/utils/emitter'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
 import { CHART_PRESET_THEMES } from '@/configs/chart'
+import { lang } from '@/main'
 
 import ElementOutline from '../../common/ElementOutline.vue'
 import ChartDataEditor from './ChartDataEditor.vue'

+ 4 - 3
src/views/Editor/Toolbar/ElementStylePanel/FrameStylePanel.vue

@@ -1,9 +1,9 @@
 <template>
   <div class="frame-style-panel">
     <div class="row">
-      <div>网页链接:</div>
-      <Input v-model:value="url" placeholder="请输入网页链接" />
-      <Button @click="updateURL()">确定</Button>
+      <div>{{ lang.ssTabWebLink }}:</div>
+      <Input v-model:value="url" :placeholder="lang.ssWebUrlPh" />
+      <Button @click="updateURL()">{{ lang.ssConfirm }}</Button>
     </div>
   </div>
 </template>
@@ -13,6 +13,7 @@ import { ref, watch, computed } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useMainStore, useSlidesStore } from '@/store'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
+import { lang } from '@/main'
 import Input from '@/components/Input.vue'
 import Button from '@/components/Button.vue'
 

+ 11 - 10
src/views/Editor/Toolbar/ElementStylePanel/ImageStylePanel.vue

@@ -8,11 +8,11 @@
     <ElementFlip />
 
     <ButtonGroup class="row" passive>
-      <Button first style="width: calc(100% / 6 * 5);" @click="clipImage()"><IconTailoring class="btn-icon" /> 裁剪图片</Button>
+      <Button first style="width: calc(100% / 6 * 5);" @click="clipImage()"><IconTailoring class="btn-icon" /> {{ lang.ssClipImage }}</Button>
       <Popover trigger="click" v-model:value="clipPanelVisible" style="width: calc(100% / 6);">
         <template #content>
           <div class="clip">
-            <div class="title">按形状:</div>
+            <div class="title">{{ lang.ssByShape }}</div>
             <div class="shape-clip">
               <div 
                 class="shape-clip-item" 
@@ -25,7 +25,7 @@
             </div>
 
             <template v-for="typeItem in ratioClipOptions" :key="typeItem.label">
-              <div class="title" v-if="typeItem.label">{{typeItem.label}}</div>
+              <div class="title" v-if="typeItem.label">{{ lang.ssByRatio.replace(/\*/g, typeItem.label) }}</div>
               <ButtonGroup class="row">
                 <Button 
                   style="flex: 1;"
@@ -42,7 +42,7 @@
     </ButtonGroup>
     
     <div class="row">
-      <div style="width: 40%;">圆角半径:</div>
+      <div style="width: 40%;">{{ lang.ssRadius }}</div>
       <NumberInput 
         :value="handleImageElement.radius || 0" 
         @update:value="value => updateImage({ radius: value })" 
@@ -61,10 +61,10 @@
     <Divider />
     
     <FileInput @change="files => replaceImage(files)">
-      <Button class="full-width-btn"><IconTransform class="btn-icon" /> 替换图片</Button>
+      <Button class="full-width-btn"><IconTransform class="btn-icon" /> {{ lang.ssReplaceImage }}</Button>
     </FileInput>
-    <Button class="full-width-btn" @click="resetImage()"><IconUndo class="btn-icon" /> 重置样式</Button>
-    <Button class="full-width-btn" @click="setBackgroundImage()"><IconTheme class="btn-icon" /> 设为背景</Button>
+    <Button class="full-width-btn" @click="resetImage()"><IconUndo class="btn-icon" /> {{ lang.ssResetStyle }}</Button>
+    <Button class="full-width-btn" @click="setBackgroundImage()"><IconTheme class="btn-icon" /> {{ lang.ssSetAsBg }}</Button>
   </div>
 </template>
 
@@ -76,6 +76,7 @@ import type { PPTImageElement, SlideBackground } from '@/types/slides'
 import { CLIPPATHS } from '@/configs/imageClip'
 import { getImageDataURL, getImageSize } from '@/utils/image'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
+import { lang } from '@/main'
 
 import ElementOutline from '../common/ElementOutline.vue'
 import ElementShadow from '../common/ElementShadow.vue'
@@ -92,13 +93,13 @@ import NumberInput from '@/components/NumberInput.vue'
 const shapeClipPathOptions = CLIPPATHS
 const ratioClipOptions = [
   {
-    label: '纵横比(正方形)',
+    label: lang.ssRatioSquare,
     children: [
       { key: '1:1', ratio: 1 / 1 },
     ],
   },
   {
-    label: '纵横比(纵向)',
+    label: lang.ssRatioPortrait,
     children: [
       { key: '2:3', ratio: 3 / 2 },
       { key: '3:4', ratio: 4 / 3 },
@@ -107,7 +108,7 @@ const ratioClipOptions = [
     ],
   },
   {
-    label: '纵横比(横向)',
+    label: lang.ssRatioLandscape,
     children: [
       { key: '3:2', ratio: 2 / 3 },
       { key: '4:3', ratio: 3 / 4 },

+ 4 - 3
src/views/Editor/Toolbar/ElementStylePanel/LatexStylePanel.vue

@@ -1,13 +1,13 @@
 <template>
   <div class="latex-style-panel">
     <div class="row">
-      <Button style="flex: 1;" @click="latexEditorVisible = true">编辑 LaTeX</Button>
+      <Button style="flex: 1;" @click="latexEditorVisible = true">{{ lang.ssEditLatex }}</Button>
     </div>
 
     <Divider />
 
     <div class="row">
-      <div style="width: 40%;">颜色:</div>
+      <div style="width: 40%;">{{ lang.ssColor }}</div>
       <Popover trigger="click" style="width: 60%;">
         <template #content>
           <ColorPicker
@@ -19,7 +19,7 @@
       </Popover>
     </div>
     <div class="row">
-      <div style="width: 40%;">粗细:</div>
+      <div style="width: 40%;">{{ lang.ssStrokeWidth }}</div>
       <NumberInput 
         :min="1"
         :max="3"
@@ -49,6 +49,7 @@ import { useMainStore, useSlidesStore } from '@/store'
 import type { PPTLatexElement } from '@/types/slides'
 import emitter, { EmitterEvents } from '@/utils/emitter'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
+import { lang } from '@/main'
 
 import ColorButton from '@/components/ColorButton.vue'
 import LaTeXEditor from '@/components/LaTeXEditor/index.vue'

+ 7 - 6
src/views/Editor/Toolbar/ElementStylePanel/LineStylePanel.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="line-style-panel">
     <div class="row">
-      <div style="width: 40%;">线条样式:</div>
+      <div style="width: 40%;">{{ lang.ssLineStyle }}</div>
       <SelectCustom style="width: 60%;">
         <template #options>
           <div class="option" v-for="item in lineStyleOptions" :key="item" @click="updateLine({ style: item })">
@@ -14,7 +14,7 @@
       </SelectCustom>
     </div>
     <div class="row">
-      <div style="width: 40%;">线条颜色:</div>
+      <div style="width: 40%;">{{ lang.ssLineColor }}</div>
       <Popover trigger="click" style="width: 60%;">
         <template #content>
           <ColorPicker
@@ -26,7 +26,7 @@
       </Popover>
     </div>
     <div class="row">
-      <div style="width: 40%;">线条宽度:</div>
+      <div style="width: 40%;">{{ lang.ssLineWidth }}</div>
       <NumberInput 
         :value="handleLineElement.width" 
         @update:value="value => updateLine({ width: value })" 
@@ -35,7 +35,7 @@
     </div>
     
     <div class="row">
-      <div style="width: 40%;">起点样式:</div>
+      <div style="width: 40%;">{{ lang.ssStartStyle }}</div>
       <SelectCustom style="width: 60%;">
         <template #options>
           <div class="option" v-for="item in lineMarkerOptions" :key="item" @click="updateLine({ points: [item, handleLineElement.points[1]] })">
@@ -48,7 +48,7 @@
       </SelectCustom>
     </div>
     <div class="row">
-      <div style="width: 40%;">终点样式:</div>
+      <div style="width: 40%;">{{ lang.ssEndStyle }}</div>
       <SelectCustom style="width: 60%;">
         <template #options>
           <div class="option" v-for="item in lineMarkerOptions" :key="item" @click="updateLine({ points: [handleLineElement.points[0], item] })">
@@ -64,7 +64,7 @@
     <Divider />
 
     <div class="row">
-      <Button style="flex: 1;" @click="updateLine({ start: handleLineElement.end, end: handleLineElement.start })"><IconSwitch /> 交换方向</Button>
+      <Button style="flex: 1;" @click="updateLine({ start: handleLineElement.end, end: handleLineElement.start })"><IconSwitch /> {{ lang.ssSwapDir }}</Button>
     </div>
 
     <Divider />
@@ -78,6 +78,7 @@ import { storeToRefs } from 'pinia'
 import { useMainStore, useSlidesStore } from '@/store'
 import type { LinePoint, LineStyleType, PPTLineElement } from '@/types/slides'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
+import { lang } from '@/main'
 
 import ElementShadow from '../common/ElementShadow.vue'
 import SVGLine from '../common/SVGLine.vue'

+ 14 - 13
src/views/Editor/Toolbar/ElementStylePanel/ShapeStylePanel.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="shape-style-panel">
     <div class="title">
-      <span>点击替换形状</span>
+      <span>{{ lang.ssClickReplaceShape }}</span>
       <IconDown />
     </div>
     <div class="shape-pool">
@@ -24,9 +24,9 @@
         :value="fillType" 
         @update:value="value => updateFillType(value as 'fill' | 'gradient' | 'pattern')"
         :options="[
-          { label: '纯色填充', value: 'fill' },
-          { label: '渐变填充', value: 'gradient' },
-          { label: '图片填充', value: 'pattern' },
+          { label: lang.ssSolidFill, value: 'fill' },
+          { label: lang.ssGradFill, value: 'gradient' },
+          { label: lang.ssImgFill, value: 'pattern' },
         ]"
       />
       <div style="width: 10px;" v-if="fillType !== 'pattern'"></div>
@@ -45,8 +45,8 @@
         @update:value="value => updateGradient({ type: value as GradientType })"
         v-else-if="fillType === 'gradient'"
         :options="[
-          { label: '线性渐变', value: 'linear' },
-          { label: '径向渐变', value: 'radial' },
+          { label: lang.ssLinearGrad, value: 'linear' },
+          { label: lang.ssRadialGrad, value: 'radial' },
         ]"
       />
     </div>
@@ -61,7 +61,7 @@
         />
       </div>
       <div class="row">
-        <div style="width: 40%;">当前色块:</div>
+        <div style="width: 40%;">{{ lang.ssCurColorBlock }}</div>
         <Popover trigger="click" style="width: 60%;">
           <template #content>
             <ColorPicker
@@ -73,7 +73,7 @@
         </Popover>
       </div>
       <div class="row" v-if="gradient.type === 'linear'">
-        <div style="width: 40%;">渐变角度:</div>
+        <div style="width: 40%;">{{ lang.ssGradAngle }}</div>
         <Slider
           style="width: 60%;"
           :min="0"
@@ -111,9 +111,9 @@
         :value="textAlign"
         @update:value="value => updateTextAlign(value as 'top' | 'middle' | 'bottom')"
       >
-        <RadioButton value="top" v-tooltip="'顶对齐'" style="flex: 1;"><IconAlignTextTopOne /></RadioButton>
-        <RadioButton value="middle" v-tooltip="'居中'" style="flex: 1;"><IconAlignTextMiddleOne /></RadioButton>
-        <RadioButton value="bottom" v-tooltip="'底对齐'" style="flex: 1;"><IconAlignTextBottomOne /></RadioButton>
+        <RadioButton value="top" v-tooltip="lang.ssAlignTop" style="flex: 1;"><IconAlignTextTopOne /></RadioButton>
+        <RadioButton value="middle" v-tooltip="lang.ssAlignMiddle" style="flex: 1;"><IconAlignTextMiddleOne /></RadioButton>
+        <RadioButton value="bottom" v-tooltip="lang.ssAlignBottom" style="flex: 1;"><IconAlignTextBottomOne /></RadioButton>
       </RadioGroup>
 
       <Divider />
@@ -128,12 +128,12 @@
 
     <div class="row">
       <CheckboxButton
-        v-tooltip="'双击连续使用'"
+        v-tooltip="lang.ssFormatPainter"
         style="flex: 1;"
         :checked="!!shapeFormatPainter"
         @click="toggleShapeFormatPainter()"
         @dblclick="toggleShapeFormatPainter(true)"
-      ><IconFormatBrush /> 形状格式刷</CheckboxButton>
+      ><IconFormatBrush /> {{ lang.ssShapePainter }}</CheckboxButton>
     </div>
   </div>
 </template>
@@ -148,6 +148,7 @@ import { getImageDataURL } from '@/utils/image'
 import emitter, { EmitterEvents } from '@/utils/emitter'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
 import useShapeFormatPainter from '@/hooks/useShapeFormatPainter'
+import { lang } from '@/main'
 
 import ElementOpacity from '../common/ElementOpacity.vue'
 import ElementOutline from '../common/ElementOutline.vue'

+ 4 - 3
src/views/Editor/Toolbar/ElementStylePanel/VideoStylePanel.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="video-style-panel">
-    <div class="title">视频预览封面</div>
+    <div class="title">{{ lang.ssVideoPoster }}</div>
     <div class="background-image-wrapper">
       <FileInput @change="files => setVideoPoster(files)">
         <div class="background-image">
@@ -11,11 +11,11 @@
       </FileInput>
     </div>
     <div class="row">
-      <Button style="flex: 1;" @click="updateVideo({ poster: '' })">重置封面</Button>
+      <Button style="flex: 1;" @click="updateVideo({ poster: '' })">{{ lang.ssResetPoster }}</Button>
     </div>
 
     <div class="row switch-row">
-      <div style="width: 40%;">自动播放:</div>
+      <div style="width: 40%;">{{ lang.ssAutoplay }}</div>
       <div class="switch-wrapper" style="width: 60%;">
         <Switch 
           :value="handleVideoElement.autoplay" 
@@ -33,6 +33,7 @@ import { useMainStore, useSlidesStore } from '@/store'
 import type { PPTVideoElement } from '@/types/slides'
 import { getImageDataURL } from '@/utils/image'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
+import { lang } from '@/main'
 
 import FileInput from '@/components/FileInput.vue'
 import Button from '@/components/Button.vue'

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

@@ -10,9 +10,10 @@
           class="center-bottom" 
           v-model:height="remarkHeight" 
           :style="{ height: `${remarkHeight}px` }"
+           v-show="false"
         />
       </div>
-      <Toolbar class="layout-content-right" />
+      <Toolbar class="layout-content-right"/>
     </div>
   </div>
 
@@ -74,7 +75,8 @@ const { dialogForExport, showSelectPanel, showSearchPanel, showNotesPanel, showM
 const closeExportDialog = () => mainStore.setDialogForExport('')
 const closeAIPPTDialog = () => mainStore.setAIPPTDialogState(false)
 
-const remarkHeight = ref(40)
+// const remarkHeight = ref(40)
+const remarkHeight = ref(0)
 
 useGlobalHotkey()
 usePasteEvent()
@@ -98,6 +100,7 @@ usePasteEvent()
 }
 .layout-content-center {
   width: calc(100% - 160px - 260px);
+  // width: calc(100% - 160px);
 
   .center-top {
     height: 40px;

Некоторые файлы не были показаны из-за большого количества измененных файлов