jack il y a 1 semaine
Parent
commit
a5c58c47f9

+ 5 - 0
src/assets/styles/prosemirror.scss

@@ -117,4 +117,9 @@
 
 .ProseMirror-selectednode {
   outline: none !important;
+}
+
+.shape-text[style*="nowrap"] .ProseMirror,
+.shape-text[style*="nowrap"] .ProseMirror-static {
+  white-space: nowrap !important;
 }

Fichier diff supprimé car celui-ci est trop grand
+ 186 - 929
src/hooks/useImport.ts


+ 21 - 0
src/types/slides.ts

@@ -137,6 +137,8 @@ interface PPTBaseElement {
   rotate: number
   link?: PPTElementLink
   name?: string
+  /** PPTX 导入时的原始 shape id,用于动画目标反查(导入后临时使用) */
+  spid?: string
 }
 
 
@@ -673,6 +675,16 @@ export type AnimationTrigger = 'click' | 'meantime' | 'auto'
  * duration: 动画持续时间
  * 
  * trigger: 动画触发方式(click - 单击时、meantime - 与上一动画同时、auto - 上一动画之后)
+ * 
+ * delay?: 延迟时间(PPTX 导入时保留)
+ * 
+ * groupIndex?: 第几次点击触发(PPTX 导入时保留,用于渲染端分组)
+ * 
+ * animIndex?: 组内顺序(PPTX 导入时保留)
+ * 
+ * direction?: 动画方向(PPTX 原始方向信息)
+ * 
+ * raw?: PPTX 原始动画数据(供渲染端扩展使用)
  */
 export interface PPTAnimation {
   id: string
@@ -681,6 +693,11 @@ export interface PPTAnimation {
   type: AnimationType
   duration: number
   trigger: AnimationTrigger
+  delay?: number
+  groupIndex?: number
+  animIndex?: number
+  direction?: string | null
+  raw?: any
 }
 
 export type SlideBackgroundType = 'solid' | 'image' | 'gradient'
@@ -764,6 +781,10 @@ export interface Slide {
   sectionTag?: SectionTag
   type?: SlideType,
   toolsArray?: Array<any>
+  /** PPTX 导入时的原始转场数据(调试用,实际播放走 turningMode) */
+  transition?: any
+  /** PPTX 导入时的 spid -> 元素 id 映射(调试用) */
+  spidMap?: Record<string, string>
 }
 
 /**

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

@@ -1,9 +1,9 @@
 import { marks } from 'prosemirror-schema-basic'
 import type { MarkSpec } from 'prosemirror-model'
 
-// ===== 原有定义 =====
+// ===== 上下标 =====
 const subscript: MarkSpec = {
-  excludes: 'subscript',
+  excludes: 'subscript verticalAlign',   // ★ 与通用 verticalAlign 互斥
   parseDOM: [
     { tag: 'sub' },
     {
@@ -15,7 +15,7 @@ const subscript: MarkSpec = {
 }
 
 const superscript: MarkSpec = {
-  excludes: 'superscript',
+  excludes: 'superscript verticalAlign',  // ★
   parseDOM: [
     { tag: 'sup' },
     {
@@ -26,9 +26,14 @@ const superscript: MarkSpec = {
   toDOM: () => ['sup', 0],
 }
 
+// ===== 删除线 / 下划线 =====
+// ★ 统一用 text-decoration-line 输出,避免两个 mark 嵌套时互相覆盖
 const strikethrough: MarkSpec = {
+  excludes: 'textDecoration',   // ★
   parseDOM: [
     { tag: 'strike' },
+    { tag: 's' },
+    { tag: 'del' },             // ★ 补齐常见标签
     {
       style: 'text-decoration',
       getAttrs: value => value === 'line-through' && null
@@ -42,6 +47,7 @@ const strikethrough: MarkSpec = {
 }
 
 const underline: MarkSpec = {
+  excludes: 'textDecoration',   // ★
   parseDOM: [
     { tag: 'u' },
     {
@@ -53,18 +59,17 @@ const underline: MarkSpec = {
       getAttrs: value => value === 'underline' && null
     },
   ],
-  toDOM: () => ['span', { style: 'text-decoration: underline;' }, 0],
+  // ★ 与 strikethrough 保持一致的简写形式
+  toDOM: () => ['span', { style: 'text-decoration-line: underline;' }, 0],
 }
 
+// ===== 颜色 / 字体 / 字号 =====
 const forecolor: MarkSpec = {
   attrs: { color: {} },
   inline: true,
   group: 'inline',
   parseDOM: [
-    {
-      style: 'color',
-      getAttrs: color => color ? { color } : {}
-    },
+    { style: 'color', getAttrs: color => (color ? { color } : {}) },
   ],
   toDOM: mark => {
     const { color } = mark.attrs
@@ -79,10 +84,7 @@ const backcolor: MarkSpec = {
   inline: true,
   group: 'inline',
   parseDOM: [
-    {
-      style: 'background-color',
-      getAttrs: backcolor => backcolor ? { backcolor } : {}
-    },
+    { style: 'background-color', getAttrs: backcolor => (backcolor ? { backcolor } : {}) },
   ],
   toDOM: mark => {
     const { backcolor } = mark.attrs
@@ -97,10 +99,7 @@ const fontsize: MarkSpec = {
   inline: true,
   group: 'inline',
   parseDOM: [
-    {
-      style: 'font-size',
-      getAttrs: fontsize => fontsize ? { fontsize } : {}
-    },
+    { style: 'font-size', getAttrs: fontsize => (fontsize ? { fontsize } : {}) },
   ],
   toDOM: mark => {
     const { fontsize } = mark.attrs
@@ -117,9 +116,9 @@ const fontname: MarkSpec = {
   parseDOM: [
     {
       style: 'font-family',
-      getAttrs: fontname => {
-        return { fontname: fontname && typeof fontname === 'string' ? fontname.replace(/["']/g, '') : '' }
-      }
+      getAttrs: fontname => ({
+        fontname: fontname && typeof fontname === 'string' ? fontname.replace(/["']/g, '') : '',
+      }),
     },
   ],
   toDOM: mark => {
@@ -144,7 +143,7 @@ const link: MarkSpec = {
         const href = (dom as HTMLElement).getAttribute('href')
         const title = (dom as HTMLElement).getAttribute('title')
         return { href, title }
-      }
+      },
     },
   ],
   toDOM: node => ['a', node.attrs, 0],
@@ -158,130 +157,131 @@ const mark: MarkSpec = {
       getAttrs: dom => {
         const index = (dom as HTMLElement).dataset.index
         return { index }
-      }
+      },
     },
   ],
   toDOM: node => ['mark', { 'data-index': node.attrs.index }, 0],
 }
 
-// ===== 新增 marks =====
+// ===== 通用 mark(与独立 mark 互斥,避免重复解析)=====
 
-// 通用 font-weight(如 100, 200, bold, bolder 等)
 const fontWeight: MarkSpec = {
   attrs: { fontWeight: {} },
   inline: true,
   group: 'inline',
   parseDOM: [
-    {
-      style: 'font-weight',
-      getAttrs: value => value ? { fontWeight: value } : {}
-    }
+    { style: 'font-weight', getAttrs: value => (value ? { fontWeight: value } : {}) },
   ],
   toDOM: mark => {
     const { fontWeight } = mark.attrs
     let style = ''
     if (fontWeight) style += `font-weight: ${fontWeight};`
     return ['span', { style }, 0]
-  }
+  },
 }
 
-// 通用 font-style(如 italic, oblique, normal)
 const fontStyle: MarkSpec = {
   attrs: { fontStyle: {} },
   inline: true,
   group: 'inline',
   parseDOM: [
-    {
-      style: 'font-style',
-      getAttrs: value => value ? { fontStyle: value } : {}
-    }
+    { style: 'font-style', getAttrs: value => (value ? { fontStyle: value } : {}) },
   ],
   toDOM: mark => {
     const { fontStyle } = mark.attrs
     let style = ''
     if (fontStyle) style += `font-style: ${fontStyle};`
     return ['span', { style }, 0]
-  }
+  },
 }
 
-// 通用 text-decoration(可包含 underline, overline, line-through 等组合)
+// ★ 只有在 text-decoration 为组合值(如 "underline line-through")时才匹配,
+//   单一值交给独立 mark(underline / strikethrough)处理
 const textDecoration: MarkSpec = {
   attrs: { textDecoration: {} },
   inline: true,
   group: 'inline',
+  excludes: 'underline strikethrough',   // ★ 与独立 mark 互斥
   parseDOM: [
     {
       style: 'text-decoration',
-      getAttrs: value => value ? { textDecoration: value } : {}
+      getAttrs: value => {
+        if (!value) return false
+        const v = String(value).trim()
+        if (v === 'underline' || v === 'line-through') return false
+        return { textDecoration: value }
+      },
     },
     {
       style: 'text-decoration-line',
-      getAttrs: value => value ? { textDecoration: value } : {}
-    }
+      getAttrs: value => {
+        if (!value) return false
+        const v = String(value).trim()
+        if (v === 'underline' || v === 'line-through') return false
+        return { textDecoration: value }
+      },
+    },
   ],
   toDOM: mark => {
     const { textDecoration } = mark.attrs
     let style = ''
     if (textDecoration) style += `text-decoration: ${textDecoration};`
     return ['span', { style }, 0]
-  }
+  },
 }
 
-// 通用 vertical-align(支持 baseline, sub, super, top, middle, bottom, 长度值等)
+// ★ 排除 sub / super,交由 subscript / superscript 处理
 const verticalAlign: MarkSpec = {
   attrs: { verticalAlign: {} },
   inline: true,
   group: 'inline',
+  excludes: 'subscript superscript',   // ★
   parseDOM: [
     {
       style: 'vertical-align',
-      getAttrs: value => value ? { verticalAlign: value } : {}
-    }
+      getAttrs: value => {
+        if (!value) return false
+        if (value === 'sub' || value === 'super') return false
+        return { verticalAlign: value }
+      },
+    },
   ],
   toDOM: mark => {
     const { verticalAlign } = mark.attrs
     let style = ''
     if (verticalAlign) style += `vertical-align: ${verticalAlign};`
     return ['span', { style }, 0]
-  }
+  },
 }
 
-// letter-spacing
 const letterSpacing: MarkSpec = {
   attrs: { letterSpacing: {} },
   inline: true,
   group: 'inline',
   parseDOM: [
-    {
-      style: 'letter-spacing',
-      getAttrs: value => value ? { letterSpacing: value } : {}
-    }
+    { style: 'letter-spacing', getAttrs: value => (value ? { letterSpacing: value } : {}) },
   ],
   toDOM: mark => {
     const { letterSpacing } = mark.attrs
     let style = ''
     if (letterSpacing) style += `letter-spacing: ${letterSpacing};`
     return ['span', { style }, 0]
-  }
+  },
 }
 
-// text-shadow
 const textShadow: MarkSpec = {
   attrs: { textShadow: {} },
   inline: true,
   group: 'inline',
   parseDOM: [
-    {
-      style: 'text-shadow',
-      getAttrs: value => value ? { textShadow: value } : {}
-    }
+    { style: 'text-shadow', getAttrs: value => (value ? { textShadow: value } : {}) },
   ],
   toDOM: mark => {
     const { textShadow } = mark.attrs
     let style = ''
     if (textShadow) style += `text-shadow: ${textShadow};`
     return ['span', { style }, 0]
-  }
+  },
 }
 
 // 从 prosemirror-schema-basic 获取的默认 marks

+ 207 - 238
src/utils/prosemirror/schema/nodes.ts

@@ -6,20 +6,29 @@ interface Attr {
   [key: string]: number | string
 }
 
+/**
+ * ★ 判断是否为 PowerPoint 列表的「悬挂缩进」
+ *
+ * PPT 项目符号列表用「负首行缩进 + 正左边距」实现:text-indent 为负(如 -36pt)
+ * 且 margin-left 为正(如 36pt)。这种组合在 PPTist 的 li::before 项目符号布局下
+ * 会导致文字偏移、对不齐,应清除(交给 li::before 正常生成符号并对齐)。
+ */
+function isHangingIndent(textIndent: string, marginLeft: string): boolean {
+  if (!textIndent || !marginLeft) return false
+
+  const indentVal = parseFloat(textIndent)
+  const marginVal = parseFloat(marginLeft)
+
+  // 负首行缩进 + 正左边距 = 悬挂缩进
+  return indentVal < 0 && marginVal > 0
+}
+
 const orderedList: NodeSpec = {
   attrs: {
-    order: {
-      default: 1,
-    },
-    listStyleType: {
-      default: '',
-    },
-    fontsize: {
-      default: '',
-    },
-    color: {
-      default: '',
-    },
+    order: { default: 1 },
+    listStyleType: { default: '' },
+    fontsize: { default: '' },
+    color: { default: '' },
   },
   content: 'list_item+',
   group: 'block',
@@ -36,8 +45,8 @@ const orderedList: NodeSpec = {
         if (color) attr['color'] = color
 
         return attr
-      }
-    }
+      },
+    },
   ],
   toDOM: (node: Node) => {
     const { order, listStyleType, fontsize, color } = node.attrs
@@ -49,22 +58,15 @@ const orderedList: NodeSpec = {
     const attr: Attr = { style }
     if (order !== 1) attr['start'] = order
 
-
     return ['ol', attr, 0]
   },
 }
 
 const bulletList: NodeSpec = {
   attrs: {
-    listStyleType: {
-      default: '',
-    },
-    fontsize: {
-      default: '',
-    },
-    color: {
-      default: '',
-    },
+    listStyleType: { default: '' },
+    fontsize: { default: '' },
+    color: { default: '' },
   },
   content: 'list_item+',
   group: 'block',
@@ -80,8 +82,8 @@ const bulletList: NodeSpec = {
         if (color) attr['color'] = color
 
         return attr
-      }
-    }
+      },
+    },
   ],
   toDOM: (node: Node) => {
     const { listStyleType, fontsize, color } = node.attrs
@@ -94,18 +96,12 @@ const bulletList: NodeSpec = {
   },
 }
 
-/*
-const listItem: NodeSpec = {
-  ..._listItem,
-  content: 'paragraph block*',
-  group: 'block',
-}
-*/
 const listItem: NodeSpec = {
   attrs: {
     textAlign: { default: '' },
     textAlignLast: { default: '' },
     textIndent: { default: '' },
+    fontsize: { default: '' },
     marginTop: { default: '' },
     marginBottom: { default: '' },
     marginLeft: { default: '' },
@@ -117,7 +113,7 @@ const listItem: NodeSpec = {
     paddingLeft: { default: '' },
     whiteSpace: { default: 'normal' },
     writingMode: { default: '' },
-    // 新增字体相关属性
+    // 字体相关
     fontWeight: { default: '' },
     fontStyle: { default: '' },
     textDecoration: { default: '' },
@@ -132,56 +128,64 @@ const listItem: NodeSpec = {
     {
       tag: 'li',
       getAttrs(dom) {
-        const el = dom as HTMLElement;
-        const style = el.style;
-
-        const textAlign = style.textAlign || '';
-        const textAlignLast = style.textAlignLast || '';
-        const textIndent = style.textIndent || '';
-        const marginTop = style.marginTop || '';
-        const marginBottom = style.marginBottom || '';
-        const marginLeft = style.marginLeft || '';
-        const marginRight = style.marginRight || '';
-        const lineHeight = style.lineHeight || '';
-        const paddingTop = style.paddingTop || '';
-        const paddingRight = style.paddingRight || '';
-        const paddingBottom = style.paddingBottom || '';
-        const paddingLeft = style.paddingLeft || '';
-        const whiteSpace = style.whiteSpace || 'normal';
-        const writingMode = style.writingMode || '';
-
-        // 读取新增属性
-        const fontWeight = style.fontWeight || '';
-        const fontStyle = style.fontStyle || '';
-        const textDecoration = style.textDecoration || '';
-        const textDecorationLine = style.textDecorationLine || '';
-        const verticalAlign = style.verticalAlign || '';
-        const letterSpacing = style.letterSpacing || '';
-        const textShadow = style.textShadow || '';
+        const el = dom as HTMLElement
+        const style = el.style
+
+        let textIndent = style.textIndent || ''
+        let marginLeft = style.marginLeft || ''
+
+        // ★ 1. 负 margin-left 一律清除(不限于悬挂缩进组合)
+        //    PPTist 的 li::before 已经提供符号占位。若 li 上再带负 margin-left,
+        //    整段文字会被拉到符号之外,和左边元素重叠。
+        //    之前只处理"负 text-indent + 正 margin-left"的组合,漏了单负
+        //    margin-left 的情况(WPS 里很常见:marL 负值 + indent 0/正数)。
+        if (marginLeft && parseFloat(marginLeft) < 0) {
+          marginLeft = ''
+        }
+
+        // ★ 2. 悬挂缩进组合(负 text-indent + 正 margin-left)仍要清除
+        if (isHangingIndent(textIndent, marginLeft)) {
+          textIndent = ''
+          marginLeft = ''
+        }
+
+        // ★ 3. 负 text-indent 一律清除
+        //    li::before 已提供首行缩进的视觉等效,再叠一个负 text-indent
+        //    会让首行越出符号左边。
+        if (textIndent && parseFloat(textIndent) < 0) {
+          textIndent = ''
+        }
 
         return {
-          textAlign,
-          textAlignLast,
+          textAlign: style.textAlign || '',
+          textAlignLast: style.textAlignLast || '',
           textIndent,
-          marginTop,
-          marginBottom,
+          // ★ 4. li 不再保留 font-size
+          //    原因:li::before { padding: 0.125em 0; margin-right: 0.4em }
+          //    的 em 按 li 的 font-size 计算。li 字号 37px 时 ::before 占位
+          //    约 43px,比 16px 字号时的 18px 多出 25px,把整段文字推到符号
+          //    右边、和左侧其他元素重叠。
+          //    li 内实际文字的字号由内部 span 决定,li 上不需要再挂字号。
+          fontsize: '',
+          marginTop: style.marginTop || '',
+          marginBottom: style.marginBottom || '',
           marginLeft,
-          marginRight,
-          lineHeight,
-          paddingTop,
-          paddingRight,
-          paddingBottom,
-          paddingLeft,
-          whiteSpace,
-          writingMode,
-          fontWeight,
-          fontStyle,
-          textDecoration,
-          textDecorationLine,
-          verticalAlign,
-          letterSpacing,
-          textShadow,
-        };
+          marginRight: style.marginRight || '',
+          lineHeight: style.lineHeight || '',
+          paddingTop: style.paddingTop || '',
+          paddingRight: style.paddingRight || '',
+          paddingBottom: style.paddingBottom || '',
+          paddingLeft: style.paddingLeft || '',
+          whiteSpace: style.whiteSpace || 'normal',
+          writingMode: style.writingMode || '',
+          fontWeight: style.fontWeight || '',
+          fontStyle: style.fontStyle || '',
+          textDecoration: style.textDecoration || '',
+          textDecorationLine: style.textDecorationLine || '',
+          verticalAlign: style.verticalAlign || '',
+          letterSpacing: style.letterSpacing || '',
+          textShadow: style.textShadow || '',
+        }
       },
     },
   ],
@@ -190,6 +194,7 @@ const listItem: NodeSpec = {
       textAlign,
       textAlignLast,
       textIndent,
+      // fontsize 不再从 attrs 取出(见下方注释)
       marginTop,
       marginBottom,
       marginLeft,
@@ -208,92 +213,82 @@ const listItem: NodeSpec = {
       verticalAlign,
       letterSpacing,
       textShadow,
-    } = node.attrs;
+    } = node.attrs
 
-    let style = '';
+    let style = ''
 
     if (textAlign && textAlign !== 'left') {
-      style += `text-align: ${textAlign};`;
+      style += `text-align: ${textAlign};`
     }
     if (textAlignLast) {
-      style += `text-align-last: ${textAlignLast};`;
+      style += `text-align-last: ${textAlignLast};`
     }
     if (textIndent) {
-      style += `text-indent: (100% - ${textIndent});`;
+      style += `text-indent: ${textIndent};`
     }
-    if (marginTop) style += `margin-top: ${marginTop};`;
-    if (marginBottom) style += `margin-bottom: ${marginBottom};`;
-    if (marginLeft) {
-      const str = String(marginLeft).trim();
-      const match = str.match(/^([+-]?\d*\.?\d+)(px|pt|em|rem|%|vw|vh)?$/i);
-      if (match) {
-        let num = parseFloat(match[1]);
-        const unit = match[2] || 'px';
-        const absNum = Math.abs(num);
-        const val = absNum + unit;
-        style += `margin-left: max(min(0px, 100% - ${val}), 0px);`;
-      } else {
-        style += `margin-left: ${marginLeft};`;
-      }
-    }
-    if (marginRight) style += `margin-right: ${marginRight};`;
+    // ★ li 上不再输出 font-size
+    //    让 li::before 的 padding/margin 按继承字号(16px)计算,
+    //    符号占位回到 ~18px,文字位置恢复正常。
+    //    实际文字的字号由内部 <span> 承担。
+    if (marginTop) style += `margin-top: ${marginTop};`
+    if (marginBottom) style += `margin-bottom: ${marginBottom};`
+    if (marginLeft) style += `margin-left: ${marginLeft};`
+    if (marginRight) style += `margin-right: ${marginRight};`
     if (lineHeight) {
-      let finalValue;
-      const str = String(lineHeight).trim();
-      if (/^-?\d+(\.\d+)?$/.test(str)) {
-        finalValue = parseFloat(str) * 1.2;
-      } else {
-        finalValue = lineHeight;
-      }
-      style += `line-height: ${finalValue};`;
+      style += `line-height: ${lineHeight};`
     }
-    else{
-      style += `line-height: 1.2;`;
+    else {
+      style += `line-height: 1.2;`
     }
-    if (paddingTop) style += `padding-top: ${paddingTop};`;
-    if (paddingRight) style += `padding-right: ${paddingRight};`;
-    if (paddingBottom) style += `padding-bottom: ${paddingBottom};`;
-    if (paddingLeft) style += `padding-left: ${paddingLeft};`;
+    if (paddingTop) style += `padding-top: ${paddingTop};`
+    if (paddingRight) style += `padding-right: ${paddingRight};`
+    if (paddingBottom) style += `padding-bottom: ${paddingBottom};`
+    if (paddingLeft) style += `padding-left: ${paddingLeft};`
     if (whiteSpace && whiteSpace !== 'normal') {
-      style += `white-space: ${whiteSpace};`;
+      style += `white-space: ${whiteSpace};`
     }
     if (writingMode) {
-      style += `writing-mode: ${writingMode};`;
+      style += `writing-mode: ${writingMode};`
+    }
+
+    // 字体相关样式
+    if (fontWeight) style += `font-weight: ${fontWeight};`
+    if (fontStyle) style += `font-style: ${fontStyle};`
+    if (textDecoration) {
+      style += `text-decoration: ${textDecoration};`
+    }
+    else if (textDecorationLine) {
+      style += `text-decoration-line: ${textDecorationLine};`
     }
+    if (verticalAlign) style += `vertical-align: ${verticalAlign};`
+    if (letterSpacing) style += `letter-spacing: ${letterSpacing};`
+    if (textShadow) style += `text-shadow: ${textShadow};`
 
-    // 新增字体相关样式输出
-    if (fontWeight) style += `font-weight: ${fontWeight};`;
-    if (fontStyle) style += `font-style: ${fontStyle};`;
-    if (textDecoration) style += `text-decoration: ${textDecoration};`;
-    if (textDecorationLine) style += `text-decoration-line: ${textDecorationLine};`;
-    if (verticalAlign) style += `vertical-align: ${verticalAlign};`;
-    if (letterSpacing) style += `letter-spacing: ${letterSpacing};`;
-    if (textShadow) style += `text-shadow: ${textShadow};`;
-
-    let isEmpty = false;
-    const firstChild = node.content.firstChild;
+    let isEmpty = false
+    const firstChild = node.content.firstChild
     if (firstChild && firstChild.type.name === 'paragraph') {
       if (firstChild.content.size === 0) {
-        isEmpty = true;
+        isEmpty = true
       }
     }
-    if (node.content.size === 0) isEmpty = true;
+    if (node.content.size === 0) isEmpty = true
 
-    const attrs: { style?: string; class?: string } = {};
-    if (style) attrs.style = style;
-    if (isEmpty) attrs.class = 'empty-li';
+    const attrs: { style?: string; class?: string } = {}
+    if (style) attrs.style = style
+    if (isEmpty) attrs.class = 'empty-li'
 
-    return ['li', attrs, 0];
+    return ['li', attrs, 0]
   },
-};
+}
 
 const paragraph: NodeSpec = {
-  whitespace: "pre",
+  whitespace: 'pre',
   attrs: {
     textAlign: { default: '' },
     textAlignLast: { default: '' },
     indent: { default: 0 },
     textIndent: { default: 0 },
+    fontsize: { default: '' },
     marginTop: { default: '' },
     marginBottom: { default: '' },
     marginLeft: { default: '' },
@@ -305,7 +300,7 @@ const paragraph: NodeSpec = {
     paddingLeft: { default: '' },
     whiteSpace: { default: 'normal' },
     writingMode: { default: '' },
-    // 新增字体相关属性
+    // 字体相关
     fontWeight: { default: '' },
     fontStyle: { default: '' },
     textDecoration: { default: '' },
@@ -320,72 +315,47 @@ const paragraph: NodeSpec = {
     {
       tag: 'p',
       getAttrs: dom => {
-        const el = dom as HTMLElement;
-        const style = el.style;
-
-        const textAlign = style.textAlign || '';
-        const textAlignLast = style.textAlignLast || '';
-        const marginTop = style.marginTop || '';
-        const marginBottom = style.marginBottom || '';
-        const marginLeft = style.marginLeft || '';
-        const marginRight = style.marginRight || '';
-        const textIndent = style.textIndent || '';
-        const lineHeight = style.lineHeight || '';
-        const paddingTop = style.paddingTop || '';
-        const paddingRight = style.paddingRight || '';
-        const paddingBottom = style.paddingBottom || '';
-        const paddingLeft = style.paddingLeft || '';
-        const whiteSpace = style.whiteSpace || 'normal';
-        const writingMode = style.writingMode || '';
-
-        // 读取新增属性
-        const fontWeight = style.fontWeight || '';
-        const fontStyle = style.fontStyle || '';
-        const textDecoration = style.textDecoration || '';
-        const textDecorationLine = style.textDecorationLine || '';
-        const verticalAlign = style.verticalAlign || '';
-        const letterSpacing = style.letterSpacing || '';
-        const textShadow = style.textShadow || '';
+        const el = dom as HTMLElement
+        const style = el.style
 
         return {
-          textAlign,
-          textAlignLast,
-          textIndent,
-          marginTop,
-          marginBottom,
-          marginLeft,
-          marginRight,
-          lineHeight,
-          paddingTop,
-          paddingRight,
-          paddingBottom,
-          paddingLeft,
-          whiteSpace,
-          writingMode,
-          fontWeight,
-          fontStyle,
-          textDecoration,
-          textDecorationLine,
-          verticalAlign,
-          letterSpacing,
-          textShadow,
-        };
+          textAlign: style.textAlign || '',
+          textAlignLast: style.textAlignLast || '',
+          textIndent: style.textIndent || '',
+          // ★ 段落级 font-size 必须保留:导入的 HTML 里空行撑高段落依赖
+          // p 上的 font-size(strut = line-height × 段落字号)。丢失后 strut
+          // 按继承的 16px 计算,小字号空行会被撑高,内容整体下移错位。
+          fontsize: style.fontSize || '',
+          marginTop: style.marginTop || '',
+          marginBottom: style.marginBottom || '',
+          marginLeft: style.marginLeft || '',
+          marginRight: style.marginRight || '',
+          lineHeight: style.lineHeight || '',
+          paddingTop: style.paddingTop || '',
+          paddingRight: style.paddingRight || '',
+          paddingBottom: style.paddingBottom || '',
+          paddingLeft: style.paddingLeft || '',
+          whiteSpace: style.whiteSpace || 'normal',
+          writingMode: style.writingMode || '',
+          fontWeight: style.fontWeight || '',
+          fontStyle: style.fontStyle || '',
+          textDecoration: style.textDecoration || '',
+          textDecorationLine: style.textDecorationLine || '',
+          verticalAlign: style.verticalAlign || '',
+          letterSpacing: style.letterSpacing || '',
+          textShadow: style.textShadow || '',
+        }
       },
     },
-    {
-      tag: 'img',
-      ignore: true,
-    },
-    {
-      tag: 'pre',
-      skip: true,
-    },
+    { tag: 'img', ignore: true },
+    { tag: 'pre', skip: true },
   ],
   toDOM: (node: Node) => {
     const {
       textAlign,
       textAlignLast,
       textIndent,
+      fontsize,
       marginTop,
       marginBottom,
       marginLeft,
@@ -404,71 +374,70 @@ const paragraph: NodeSpec = {
       verticalAlign,
       letterSpacing,
       textShadow,
-    } = node.attrs;
+    } = node.attrs
 
-    let style = '';
+    let style = ''
 
     if (textAlign && textAlign !== 'left') {
-      style += `text-align: ${textAlign};`;
+      style += `text-align: ${textAlign};`
     }
     if (textAlignLast) {
-      style += `text-align-last: ${textAlignLast};`;
+      style += `text-align-last: ${textAlignLast};`
     }
     if (textIndent) {
-      style += `text-indent: (100% - ${textIndent});`;
+      style += `text-indent: ${textIndent};`
     }
-    if (marginTop) style += `margin-top: ${marginTop};`;
-    if (marginBottom) style += `margin-bottom: ${marginBottom};`;
-    if (marginLeft) style += `margin-left: ${marginLeft};`;
-    if (marginRight) style += `margin-right: ${marginRight};`;
+    // ★ 段落级字号回写:空行撑高、CSS strut 都依赖 p 上的 font-size
+    if (fontsize) style += `font-size: ${fontsize};`
+    if (marginTop) style += `margin-top: ${marginTop};`
+    if (marginBottom) style += `margin-bottom: ${marginBottom};`
+    if (marginLeft) style += `margin-left: ${marginLeft};`
+    if (marginRight) style += `margin-right: ${marginRight};`
     if (lineHeight) {
-      let finalValue;
-      const str = String(lineHeight).trim();
-      if (/^-?\d+(\.\d+)?$/.test(str)) {
-        finalValue = parseFloat(str) * 1.2;
-      } else {
-        finalValue = lineHeight;
-      }
-      style += `line-height: ${finalValue};`;
+      style += `line-height: ${lineHeight};`
     }
-    else{
-      style += `line-height: 1.2;`;
+    else {
+      style += `line-height: 1.2;`
     }
-    if (paddingTop) style += `padding-top: ${paddingTop};`;
-    if (paddingRight) style += `padding-right: ${paddingRight};`;
-    if (paddingBottom) style += `padding-bottom: ${paddingBottom};`;
-    if (paddingLeft) style += `padding-left: ${paddingLeft};`;
+    if (paddingTop) style += `padding-top: ${paddingTop};`
+    if (paddingRight) style += `padding-right: ${paddingRight};`
+    if (paddingBottom) style += `padding-bottom: ${paddingBottom};`
+    if (paddingLeft) style += `padding-left: ${paddingLeft};`
     if (whiteSpace && whiteSpace !== 'normal') {
-      style += `white-space: ${whiteSpace};`;
+      style += `white-space: ${whiteSpace};`
     }
     if (writingMode) {
-      style += `writing-mode: ${writingMode};`;
+      style += `writing-mode: ${writingMode};`
     }
 
-    // 新增字体相关样式输出
-    if (fontWeight) style += `font-weight: ${fontWeight};`;
-    if (fontStyle) style += `font-style: ${fontStyle};`;
-    if (textDecoration) style += `text-decoration: ${textDecoration};`;
-    if (textDecorationLine) style += `text-decoration-line: ${textDecorationLine};`;
-    if (verticalAlign) style += `vertical-align: ${verticalAlign};`;
-    if (letterSpacing) style += `letter-spacing: ${letterSpacing};`;
-    if (textShadow) style += `text-shadow: ${textShadow};`;
-
-    const attrs: { style?: string; class?: string } = {};
-    if (style) attrs.style = style;
-    return ['p', attrs, 0];
+    // 字体相关样式
+    if (fontWeight) style += `font-weight: ${fontWeight};`
+    if (fontStyle) style += `font-style: ${fontStyle};`
+    if (textDecoration) {
+      style += `text-decoration: ${textDecoration};`
+    }
+    else if (textDecorationLine) {
+      style += `text-decoration-line: ${textDecorationLine};`
+    }
+    if (verticalAlign) style += `vertical-align: ${verticalAlign};`
+    if (letterSpacing) style += `letter-spacing: ${letterSpacing};`
+    if (textShadow) style += `text-shadow: ${textShadow};`
+
+    const attrs: { style?: string; class?: string } = {}
+    if (style) attrs.style = style
+    return ['p', attrs, 0]
   },
-};
+}
 
 const hardBreak: NodeSpec = {
-  inline: true,       // 内联节点
-  group: 'inline',    // 属于 inline 组
-  selectable: false,  // 不可被光标单独选中
+  inline: true,
+  group: 'inline',
+  selectable: false,
   parseDOM: [{ tag: 'br' }],
   toDOM() {
-    return ['br'];
+    return ['br']
   },
-};
+}
 
 const {
   doc,
@@ -484,5 +453,5 @@ export default {
   'ordered_list': orderedList,
   'bullet_list': bulletList,
   'list_item': listItem,
-  hard_break: hardBreak,  // 新增
-}
+  hard_break: hardBreak,
+}

+ 65 - 13
src/views/Screen/hooks/useExecPlay.ts

@@ -1,4 +1,4 @@
-import { onMounted, onUnmounted, ref } from 'vue'
+import { nextTick, onMounted, onUnmounted, ref } from 'vue'
 import { throttle } from 'lodash'
 import { storeToRefs } from 'pinia'
 import { useSlidesStore } from '@/store'
@@ -20,11 +20,29 @@ export default () => {
   // 最小已播放页面索引
   const playedSlidesMinIndex = ref(slideIndex.value)
 
+  // ★ 兜底定时器集合:动画若因中断/元素卸载导致 animationend 不触发,
+  // 用定时器强制收尾,避免 inAnimation 卡死(进而翻页/退出失效)
+  const fallbackTimers: ReturnType<typeof setTimeout>[] = []
+
+  // 清理所有兜底定时器
+  const clearFallbackTimers = () => {
+    while (fallbackTimers.length) {
+      clearTimeout(fallbackTimers.shift())
+    }
+  }
+
   // 执行元素动画
   const runAnimation = () => {
     // 正在执行动画时,禁止其他新的动画开始
     if (inAnimation.value) return
 
+    // ★ 越界保护:autoNext 自动连播到最后一条时,animationIndex 会等于 length,
+    // 此时没有更多动画可播,直接复位 inAnimation 避免卡死
+    if (animationIndex.value >= formatedAnimations.value.length) {
+      inAnimation.value = false
+      return
+    }
+
     const { animations, autoNext } = formatedAnimations.value[animationIndex.value]
     animationIndex.value += 1
 
@@ -32,17 +50,36 @@ export default () => {
     inAnimation.value = true
 
     let endAnimationCount = 0
+    let finished = false
+
+    // 统一收尾:当该位置的全部动画都结束(或不存在元素)后,标记完成并尝试继续
+    const checkAndFinish = () => {
+      if (finished) return
+      if (endAnimationCount === animations.length) {
+        finished = true
+        inAnimation.value = false
+        if (autoNext) runAnimation()
+      }
+    }
+
+    // 该位置没有任何动画(理论上不会发生),直接收尾避免卡死
+    if (animations.length === 0) {
+      checkAndFinish()
+      return
+    }
 
     // 依次执行该位置中的全部动画
     for (const animation of animations) {
       const elRef: HTMLElement | null = document.querySelector(`#screen-element-${animation.elId} [class^=base-element-]`)
       if (!elRef) {
+        // 元素尚未渲染或不存在:视为该动画立即完成,避免 inAnimation 卡死导致翻页/退出失效
         endAnimationCount += 1
+        checkAndFinish()
         continue
       }
 
       const animationName = `${ANIMATION_CLASS_PREFIX}${animation.effect}`
-      
+
       // 执行动画前先清除原有的动画状态(如果有)
       elRef.style.removeProperty('--animate-duration')
       for (const classname of elRef.classList) {
@@ -53,21 +90,28 @@ export default () => {
       elRef.style.setProperty('--animate-duration', `${animation.duration}ms`)
       elRef.classList.add(animationName, `${ANIMATION_CLASS_PREFIX}animated`)
 
-      // 执行动画结束,将“退场”以外的动画状态清除
-      const handleAnimationEnd = () => {
+      // 单个动画的收尾(清理类名 + 推进计数),只执行一次
+      let oneFinished = false
+      const finishOne = () => {
+        if (oneFinished) return
+        oneFinished = true
+        elRef.style.removeProperty('--animate-duration')
         if (animation.type !== 'out') {
-          elRef.style.removeProperty('--animate-duration')
           elRef.classList.remove(animationName, `${ANIMATION_CLASS_PREFIX}animated`)
         }
-
-        // 判断该位置上的全部动画都已经结束后,标记动画执行完成,并尝试继续向下执行(如果有需要)
         endAnimationCount += 1
-        if (endAnimationCount === animations.length) {
-          inAnimation.value = false
-          if (autoNext) runAnimation()
-        }
+        checkAndFinish()
       }
+
+      // 正常结束
+      const handleAnimationEnd = () => finishOne()
       elRef.addEventListener('animationend', handleAnimationEnd, { once: true })
+      // 动画被中断(如元素隐藏/卸载、CSS 动画被强制停止)也会触发
+      elRef.addEventListener('animationcancel', handleAnimationEnd, { once: true })
+
+      // ★ 兜底:即使 animationend/animationcancel 都未触发,也在 duration + 缓冲后强制收尾
+      const fallbackTimer = setTimeout(finishOne, animation.duration + 200)
+      fallbackTimers.push(fallbackTimer)
     }
   }
 
@@ -75,7 +119,8 @@ export default () => {
     const firstAnimations = formatedAnimations.value[0]
     if (firstAnimations && firstAnimations.animations.length) {
       const autoExecFirstAnimations = firstAnimations.animations.every(item => item.trigger === 'auto' || item.trigger === 'meantime')
-      if (autoExecFirstAnimations) runAnimation()
+      // 等 DOM 完全渲染后再自动播放,避免元素尚未挂载导致 elRef 为 null
+      if (autoExecFirstAnimations) nextTick(() => runAnimation())
     }
   })
 
@@ -106,7 +151,14 @@ export default () => {
       autoPlayTimer.value = 0
     }
   }
-  onUnmounted(closeAutoPlay)
+
+  // ★ 组件卸载时彻底清理:自动播放定时器 + 动画兜底定时器 + 动画状态,
+  // 避免按 ESC 退出放映时,未播完的动画残留导致状态卡死
+  onUnmounted(() => {
+    closeAutoPlay()
+    clearFallbackTimers()
+    inAnimation.value = false
+  })
 
   // 循环放映
   const loopPlay = ref(false)

+ 8 - 1
vite.config.ts

@@ -44,5 +44,12 @@ export default defineConfig({
     alias: {
       '@': fileURLToPath(new URL('./src', import.meta.url))
     }
-  }
+  },
+  optimizeDeps: {
+    // ★ pptxtojson 是本地魔改版(dist 由 rollup 手动构建)。
+    // 若参与依赖预打包(node_modules/.vite/deps),修改 dist 后 dev server
+    // 不会自动重新预打包,导致「改了解析器但页面始终用旧版」。
+    // exclude 后 dev 直接从磁盘读取最新 dist,重建后刷新浏览器即可生效。
+    exclude: ['pptxtojson'],
+  },
 })

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff