jvs-ppt-ui 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +3 -0
  2. package/dist/lib/jvs-ppt-ui.es.js +22611 -21764
  3. package/dist/lib/jvs-ppt-ui.umd.js +102 -101
  4. package/dist/lib/style.css +1 -1
  5. package/package.json +7 -7
  6. package/scripts/check_connectors.cjs +25 -0
  7. package/scripts/check_slide1.cjs +11 -0
  8. package/scripts/check_slide123.cjs +25 -0
  9. package/scripts/convert-fonts-to-woff2.cjs +97 -0
  10. package/scripts/extract-slides.ts +149 -0
  11. package/scripts/find_candidate_pptx.cjs +34 -0
  12. package/scripts/find_orange_slide.cjs +29 -0
  13. package/scripts/generate-complete-ppt-preview.js +385 -0
  14. package/scripts/inspect_315_327.cjs +19 -0
  15. package/scripts/mcp-smoke-test.js +568 -0
  16. package/scripts/patch-pptxtojson.cjs +81 -0
  17. package/scripts/verify-fonts.cjs +20 -0
  18. package/src/App.vue +53 -53
  19. package/src/components/AIPptDialog.vue +2 -0
  20. package/src/components/CanvasArea.vue +124 -62
  21. package/src/components/Header.vue +364 -142
  22. package/src/components/PptEditor.vue +26 -15
  23. package/src/components/PresentationMode.vue +24 -4
  24. package/src/components/SidebarRight.vue +8 -1
  25. package/src/components/SvgChart.vue +5 -2
  26. package/src/components/TemplateGallery.vue +1321 -866
  27. package/src/components/Toolbar.vue +243 -22
  28. package/src/components/canvas/CanvasTransformHandles.vue +5 -1
  29. package/src/components/canvas/elements/CanvasShapeElement.vue +98 -19
  30. package/src/components/canvas/elements/CanvasTextElement.vue +39 -9
  31. package/src/components/canvas/textSelectionOverlay.ts +305 -0
  32. package/src/composables/canvas/useCanvasAlignment.ts +286 -238
  33. package/src/composables/canvas/useCanvasContextMenu.ts +53 -22
  34. package/src/composables/canvas/useCanvasFormatting.ts +35 -12
  35. package/src/composables/canvas/useCanvasTable.ts +52 -24
  36. package/src/composables/canvas/useCanvasText.ts +12 -0
  37. package/src/composables/canvas/useCanvasTransform.ts +2 -2
  38. package/src/composables/sidebar/useSidebarElementTransform.ts +5 -5
  39. package/src/composables/sidebar/useSidebarShapeFormat.ts +2 -2
  40. package/src/composables/useSlides.ts +482 -127
  41. package/src/style.css +88 -0
  42. package/src/types/index.ts +4 -0
  43. package/src/utils/pptxShapes.ts +1 -1
  44. package/src/utils/pptxShapesLazy.ts +39 -0
  45. package/src/utils/svgPathParser.ts +12 -1
package/src/App.vue CHANGED
@@ -1,53 +1,53 @@
1
- <script setup lang="ts">
2
- import { ref } from 'vue'
3
- import PptEditor from './components/PptEditor.vue'
4
- import type { DeckData, PptEditorApi, UploadResourceContext } from './types'
5
-
6
- const editorRef = ref<PptEditorApi | null>(null)
7
-
8
- function handleSave(deck: DeckData) {
9
- console.log('[JVS-PPT 独立运行模式] 保存幻灯片:', deck)
10
- }
11
-
12
- function handleReady(api: PptEditorApi) {
13
- console.log('[JVS-PPT 独立运行模式] 编辑器初始化完成:', api)
14
- }
15
-
16
- /**
17
- * 模拟资源上传函数
18
- * 覆盖场景:导入 PPTX/JSON、上传图片、视频、音频、剪贴板粘贴、占位符上传、背景上传等
19
- */
20
- async function handleCustomUpload(fileOrBlob: File | Blob, context: UploadResourceContext): Promise<string> {
21
- console.log('[模拟自定义上传函数触发] 接收到待上传资源:', {
22
- type: context.type, // 资源类型: 'image' | 'video' | 'audio'
23
- source: context.source, // 触发来源: 'file-upload' | 'pptx-import' | 'json-import' | 'paste' | 'placeholder' | 'background'
24
- filename: context.filename,
25
- size: `${(fileOrBlob.size / 1024).toFixed(2)} KB`,
26
- fileOrBlob
27
- })
28
-
29
- // 1. 模拟网络上传请求耗时
30
- await new Promise(resolve => setTimeout(resolve, 300))
31
-
32
- // 2. 实际业务生产代码范例:
33
- // const formData = new FormData()
34
- // formData.append('file', fileOrBlob, context.filename || `upload_${Date.now()}`)
35
- // formData.append('type', context.type)
36
- // const res = await fetch('/api/oss/upload', { method: 'POST', body: formData }).then(r => r.json())
37
- // return res.data.url
38
-
39
- // 3. 模拟环境:生成可正常渲染的地址作为模拟的服务器返回 URL
40
- const mockServerUrl = URL.createObjectURL(fileOrBlob)
41
- console.log(`[模拟上传成功] 已返回服务器文件地址:`, mockServerUrl)
42
- return mockServerUrl
43
- }
44
- </script>
45
-
46
- <template>
47
- <!-- :custom-upload="handleCustomUpload" -->
48
- <PptEditor ref="editorRef" :custom-upload="handleCustomUpload" :enable-mcp="true" :enable-ai="true"
49
- ai-api-url="/jvs-dam/api/ppt/ai-replace" :enable-material="true"
50
- material-tree-api-url="/jvs-dam/api/dam/material/tree" material-page-api-url="/jvs-dam/api/dam/material/page"
51
- material-api-url="/jvs-dam/api/dam/material/tree" material-save-api-url="/jvs-dam/api/dam/material/save"
52
- @save="handleSave" @ready="handleReady" />
53
- </template>
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import PptEditor from './components/PptEditor.vue'
4
+ import type { DeckData, PptEditorApi, UploadResourceContext } from './types'
5
+
6
+ const editorRef = ref<PptEditorApi | null>(null)
7
+
8
+ function handleSave(deck: DeckData) {
9
+ console.log('[JVS-PPT 独立运行模式] 保存幻灯片:', deck)
10
+ }
11
+
12
+ function handleReady(api: PptEditorApi) {
13
+ console.log('[JVS-PPT 独立运行模式] 编辑器初始化完成:', api)
14
+ }
15
+
16
+ /**
17
+ * 模拟资源上传函数
18
+ * 覆盖场景:导入 PPTX/JSON、上传图片、视频、音频、剪贴板粘贴、占位符上传、背景上传等
19
+ */
20
+ async function handleCustomUpload(fileOrBlob: File | Blob, context: UploadResourceContext): Promise<string> {
21
+ console.log('[模拟自定义上传函数触发] 接收到待上传资源:', {
22
+ type: context.type, // 资源类型: 'image' | 'video' | 'audio'
23
+ source: context.source, // 触发来源: 'file-upload' | 'pptx-import' | 'json-import' | 'paste' | 'placeholder' | 'background'
24
+ filename: context.filename,
25
+ size: `${(fileOrBlob.size / 1024).toFixed(2)} KB`,
26
+ fileOrBlob
27
+ })
28
+
29
+ // 1. 模拟网络上传请求耗时
30
+ await new Promise(resolve => setTimeout(resolve, 300))
31
+
32
+ // 2. 实际业务生产代码范例:
33
+ // const formData = new FormData()
34
+ // formData.append('file', fileOrBlob, context.filename || `upload_${Date.now()}`)
35
+ // formData.append('type', context.type)
36
+ // const res = await fetch('/api/oss/upload', { method: 'POST', body: formData }).then(r => r.json())
37
+ // return res.data.url
38
+
39
+ // 3. 模拟环境:生成可正常渲染的地址作为模拟的服务器返回 URL
40
+ const mockServerUrl = URL.createObjectURL(fileOrBlob)
41
+ console.log(`[模拟上传成功] 已返回服务器文件地址:`, mockServerUrl)
42
+ return mockServerUrl
43
+ }
44
+ </script>
45
+
46
+ <template>
47
+ <!-- :custom-upload="handleCustomUpload" -->
48
+ <PptEditor ref="editorRef" :custom-upload="handleCustomUpload" :enable-mcp="true" :enable-ai="true"
49
+ ai-api-url="/jvs-dam/api/ppt/ai-replace" :enable-material="true"
50
+ material-tree-api-url="/jvs-dam/api/dam/material/tree" material-page-api-url="/jvs-dam/api/dam/material/page"
51
+ material-api-url="/jvs-dam/api/dam/material/tree" material-save-api-url="/jvs-dam/api/dam/material/save"
52
+ @save="handleSave" @ready="handleReady" />
53
+ </template>
@@ -2,6 +2,7 @@
2
2
  import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
3
3
  import { useSlides } from '../composables/useSlides'
4
4
  import { compileSchemaToSlides } from '../utils/dslCompiler'
5
+ import { ensurePptxShapes } from '../utils/pptxShapesLazy'
5
6
 
6
7
  const props = defineProps<{
7
8
  visible: boolean
@@ -352,6 +353,7 @@ const startGenerating = async () => {
352
353
 
353
354
  streamCodeText.value += `> PPTX 解析完成,正在绑定排版与动画...\n`
354
355
 
356
+ await ensurePptxShapes() // 解析 PPTist 原生格式前确保形状预设表就绪
355
357
  let parsedResult: any = null
356
358
  const currentParser = pptParserFn()
357
359
  if (currentParser) {
@@ -3,7 +3,7 @@ import { ref, watch, onMounted, onUnmounted, nextTick, computed } from 'vue'
3
3
  import { useSlides, sanitizeNbspInHtml } from '../composables/useSlides'
4
4
  import SvgChart from './SvgChart.vue'
5
5
  import { SHAPE_LIST as CAT_SHAPE_LIST } from '../utils/shapes'
6
- import { SHAPE_PATH_FORMULAS } from '../utils/pptxShapes'
6
+ import { shapeFormulas } from '../utils/pptxShapesLazy'
7
7
  import { promoteListIndentation, restoreParagraphIndentation, collectBlockIndents, convertListElements, reapplyMissingListIndents, removeListStyle, mergeAdjacentSameTagLists, isContentListDominated, formatListStyleTypeForCss, snapSelectionStartToNextBlock, rangeTextLengthIn, collectBlockStyles, reapplyBlockStylesToListItems } from '../utils/listIndent'
8
8
  import CanvasContextMenu from './canvas/CanvasContextMenu.vue'
9
9
  import CanvasSmartGuides from './canvas/CanvasSmartGuides.vue'
@@ -19,7 +19,7 @@ import CanvasSymbolPanel from './canvas/panels/CanvasSymbolPanel.vue'
19
19
  import CanvasSearchPanel from './canvas/panels/CanvasSearchPanel.vue'
20
20
  import CanvasSelectionPanel from './canvas/panels/CanvasSelectionPanel.vue'
21
21
 
22
- import { useCanvasTable } from '../composables/canvas/useCanvasTable'
22
+ import { useCanvasTable, cleanTableDOMForSaving } from '../composables/canvas/useCanvasTable'
23
23
  import { useCanvasAlignment } from '../composables/canvas/useCanvasAlignment'
24
24
  import { useCanvasShortcuts } from '../composables/canvas/useCanvasShortcuts'
25
25
  import { useCanvasContextMenu } from '../composables/canvas/useCanvasContextMenu'
@@ -61,7 +61,7 @@ const {
61
61
  activeSlide, selectedElementId, selectedElementIds, deleteElement, combineElements, uncombineElement, updateGroupBounds, zoomRatio, fitTrigger, activeTool, pendingTextType, pendingShapeSubtype, pendingShapeViewBox, pendingLineContent, activeIndex, slides, undo, redo, notify,
62
62
  showSearchPanel, showSelectionPanel, activeSearchTab, searchText, replaceText, isCaseSensitive, searchResults, currentResultIndex,
63
63
  nextSearchMatch, prevSearchMatch, replaceCurrent, replaceAll, formulaDialogVisible, currentEditingFormulaEl, renderLatexToSvg,
64
- editingElementId, symbolPanelVisible, addElement, isPresenting, startPresentation, clipboard, slideClipboard, pasteSlideData, pasteElementsData, handleUnifiedPaste, shapeFormatPainter,
64
+ editingElementId, symbolPanelVisible, addElement, isPresenting, startPresentation, clipboard, slideClipboard, pasteSlideData, pasteElementsData, copySelectedElements, handleUnifiedPaste, shapeFormatPainter,
65
65
  activeTab: activeTabRaw, isOpenedFromFloatToolbar, formatedAnimations, logicalWidth, logicalHeight, pauseHistory, isCanvasDragging, activeSlideVersion, addHistorySnapshot, normalizeHtmlFontFamily,
66
66
  globalThemeColor, globalFontColor, globalFontFamily, globalBgColor, getActiveSlideThemeRecipe, croppingElementId,
67
67
  isMasterEditingView, slideMasters, activeMasterId, activeLayoutId, activeLayout, sortSlideElements,
@@ -142,6 +142,26 @@ const viewportContainerRef = ref<HTMLElement | null>(null)
142
142
  let viewportResizeObserver: ResizeObserver | null = null
143
143
  const selectedElementWidth = ref('0px')
144
144
  const selectedElementHeight = ref('0px')
145
+
146
+ // 拖拽期间全局图元尺寸缓存池:彻底消除拖动期间的 Forced Synchronous Layout(强制回流与 DOM 读取)
147
+ const activeDragDimensions = new Map<string, { width: number; height: number }>()
148
+
149
+ // 渲染期图元尺寸记忆化:模板对同一元素每渲染批会调 getElementWidthHeight 2-4 次,
150
+ // 签名含全部输入(style 尺寸/排版项、内容长度+首尾采样、选中 RO 尺寸),命中则免重复 DOM 测量
151
+ let elementDimCache = new WeakMap<object, { sig: string; dims: { width: number; height: number } }>()
152
+ watch(activeIndex, () => {
153
+ elementDimCache = new WeakMap()
154
+ cachedRenderElements = []
155
+ cachedRenderElementsSlideId = ''
156
+ cachedRenderElementsCount = -1
157
+ masterCacheMap.clear()
158
+ })
159
+ if (typeof document !== 'undefined' && (document as any).fonts?.ready) {
160
+ ;(document as any).fonts.ready.then(() => {
161
+ elementDimCache = new WeakMap()
162
+ }).catch(() => {})
163
+ }
164
+
145
165
  const isDraggingLine = ref(false)
146
166
  const isDraggingChild = ref(false)
147
167
  const isDraggingLineControlPoint = ref(false)
@@ -231,6 +251,7 @@ const {
231
251
  selectElement: (id: string, e?: MouseEvent) => selectElement(id, e),
232
252
  updateSelectionBorders: (tableEl: HTMLTableElement) => updateSelectionBorders(tableEl),
233
253
  deleteElement: (id: string) => deleteElement(id),
254
+ copySelectedElements: () => copySelectedElements(),
234
255
  handleUnifiedPaste: () => handleUnifiedPaste(),
235
256
  getElementPixels: (el: any) => getElementPixels(el),
236
257
  notify
@@ -344,7 +365,6 @@ const {
344
365
  getSelectionBounds,
345
366
  expandSelection,
346
367
  updateSelectionBorders,
347
- cleanTableDOMForSaving,
348
368
  doSelectColumn,
349
369
  selectColumnCells,
350
370
  doSelectRow,
@@ -581,6 +601,8 @@ const {
581
601
  logicalWidth,
582
602
  logicalHeight,
583
603
  getElementPixels: (el: any) => getElementPixels(el),
604
+ findElementInSlide: (id: any) => findElementInSlide(id),
605
+ findParentGroup: (id: any) => findParentGroup(id),
584
606
  addHistorySnapshot
585
607
  })
586
608
 
@@ -962,21 +984,6 @@ function getResizeCursor(handle: string, el: any) {
962
984
  return getHandleCursor(handle, el?.style?.rotate || 0, !!el?.style?.flipH, !!el?.style?.flipV)
963
985
  }
964
986
 
965
- // 拖拽期间全局图元尺寸缓存池:彻底消除拖动期间的 Forced Synchronous Layout(强制回流与 DOM 读取)
966
- const activeDragDimensions = new Map<string, { width: number; height: number }>()
967
-
968
- // 渲染期图元尺寸记忆化:模板对同一元素每渲染批会调 getElementWidthHeight 2-4 次,
969
- // 签名含全部输入(style 尺寸/排版项、内容长度+首尾采样、选中 RO 尺寸),命中则免重复 DOM 测量
970
- let elementDimCache = new WeakMap<object, { sig: string; dims: { width: number; height: number } }>()
971
- watch(activeIndex, () => {
972
- elementDimCache = new WeakMap()
973
- })
974
- if (typeof document !== 'undefined' && (document as any).fonts?.ready) {
975
- ;(document as any).fonts.ready.then(() => {
976
- elementDimCache = new WeakMap()
977
- }).catch(() => {})
978
- }
979
-
980
987
  const getOverlayWidth = (el: any) => {
981
988
  if (el.type === 'line') {
982
989
  return el.style?.width || (el.width !== undefined ? `${el.width}px` : '1px')
@@ -1451,6 +1458,9 @@ function fitToScreen() {
1451
1458
  const ch = viewportContainerRef.value.clientHeight
1452
1459
 
1453
1460
  const padding = 60
1461
+ // 防御:若容器正在过渡动画或不可见时,clientWidth/Height 为 0 或 <= padding,此时绝不能计算并将 zoomRatio 设为 20%
1462
+ if (cw <= padding || ch <= padding) return
1463
+
1454
1464
  const scale = Math.min((cw - padding) / logicalWidth.value, (ch - padding) / logicalHeight.value)
1455
1465
  const targetZoom = Math.max(20, Math.min(200, Math.round(scale * 100)))
1456
1466
 
@@ -1551,6 +1561,17 @@ watch(selectedElementId, (newId, oldId) => {
1551
1561
  }
1552
1562
  if (oldEl.type === 'shape') oldEl.text = htmlVal
1553
1563
  else {
1564
+ // 表格:退出编辑存档前剥离编辑态运行时属性(contenteditable/选区边框等),
1565
+ // 否则会烙进 content(退出后单元格仍可聚焦、蓝边框残留)
1566
+ if (htmlVal.includes('<table')) {
1567
+ const div = document.createElement('div')
1568
+ div.innerHTML = htmlVal
1569
+ const tableEl = div.querySelector('table')
1570
+ if (tableEl) {
1571
+ cleanTableDOMForSaving(tableEl)
1572
+ htmlVal = div.innerHTML
1573
+ }
1574
+ }
1554
1575
  oldEl.content = htmlVal
1555
1576
  if (!isMasterOrPlaceholder(oldEl) && oldContainer.offsetHeight > 0) {
1556
1577
  oldEl.height = `${oldContainer.offsetHeight}px`
@@ -1852,6 +1873,11 @@ onUnmounted(() => {
1852
1873
  if (selectedElementResizeObserver) {
1853
1874
  selectedElementResizeObserver.disconnect()
1854
1875
  }
1876
+ cachedRenderElements = []
1877
+ cachedRenderElementsSlideId = ''
1878
+ cachedRenderElementsCount = -1
1879
+ masterCacheMap.clear()
1880
+ activeDragDimensions.clear()
1855
1881
  })
1856
1882
 
1857
1883
  function handleWindowMouseDown(e: MouseEvent) {
@@ -2763,7 +2789,7 @@ function handleCanvasMouseDown(e: MouseEvent) {
2763
2789
  if (match.keypoints) {
2764
2790
  keypoints = [...match.keypoints]
2765
2791
  } else {
2766
- const formulaObj = SHAPE_PATH_FORMULAS[match.pathFormula]
2792
+ const formulaObj = shapeFormulas()[match.pathFormula]
2767
2793
  if (formulaObj && formulaObj.defaultValue) {
2768
2794
  keypoints = [...formulaObj.defaultValue]
2769
2795
  }
@@ -2899,7 +2925,7 @@ function updateCreationPosition(e: MouseEvent) {
2899
2925
  createdElement.style.width = `${widthPx.toFixed(0)}px`
2900
2926
  createdElement.style.height = `${heightPx.toFixed(0)}px`
2901
2927
  if (createdElement.pathFormula) {
2902
- const formulaObj = SHAPE_PATH_FORMULAS[createdElement.pathFormula]
2928
+ const formulaObj = shapeFormulas()[createdElement.pathFormula]
2903
2929
  if (formulaObj) {
2904
2930
  const logicalW = parseFloat(createdElement.style.width) || 120
2905
2931
  const logicalH = parseFloat(createdElement.style.height) || 120
@@ -3021,7 +3047,7 @@ function stopCreationDrag() {
3021
3047
  if (match.keypoints) {
3022
3048
  createdElement.keypoints = [...match.keypoints]
3023
3049
  } else {
3024
- const formulaObj = SHAPE_PATH_FORMULAS[match.pathFormula]
3050
+ const formulaObj = shapeFormulas()[match.pathFormula]
3025
3051
  if (formulaObj && formulaObj.defaultValue) {
3026
3052
  createdElement.keypoints = [...formulaObj.defaultValue]
3027
3053
  }
@@ -3029,7 +3055,7 @@ function stopCreationDrag() {
3029
3055
  }
3030
3056
  // If shape has path formula, generate initial correct path and viewBox
3031
3057
  if (createdElement.pathFormula) {
3032
- const formulaObj = SHAPE_PATH_FORMULAS[createdElement.pathFormula]
3058
+ const formulaObj = shapeFormulas()[createdElement.pathFormula]
3033
3059
  if (formulaObj) {
3034
3060
  const logicalW = parseFloat(createdElement.style.width) || 120
3035
3061
  const logicalH = parseFloat(createdElement.style.height) || 120
@@ -3631,12 +3657,25 @@ function handleElementDblClick(e: MouseEvent, el: any) {
3631
3657
  target = activeSelectedEl
3632
3658
  }
3633
3659
  }
3660
+ // 已在编辑该元素时的双击 = 原生选词,保持选区不重进编辑
3661
+ // (重跑 startEditingShapeText 会覆盖 initialTextContent 变更基线)
3662
+ if (isEditingText.value && (selectedElementId.value === target.id || editingElementId?.value === target.id)) {
3663
+ return
3664
+ }
3634
3665
  if (isImagePlaceholder(target)) {
3635
3666
  triggerPlaceholderImageUpload(target.id)
3636
3667
  } else if (target.type === 'shape') {
3637
3668
  startEditingShapeText(target, e)
3638
3669
  } else if (target.type === 'text') {
3639
- startEditingTextElement(target.id, e)
3670
+ if (target.content?.includes?.('<table')) {
3671
+ // 表格元素必须走 handleTableDblClick(td 动态设 contenteditable);
3672
+ // startEditingTextElement 对表格无效——容器 contenteditable=false、焦点落
3673
+ // BODY,表现为"双击后进入了编辑态却打不了字"。真实双击常因两击间选中态
3674
+ // 重渲染被重定向到 .main-slide,经四层兜底进到这里
3675
+ handleTableDblClick(e, target)
3676
+ } else {
3677
+ startEditingTextElement(target.id, e)
3678
+ }
3640
3679
  }
3641
3680
  }
3642
3681
 
@@ -4036,6 +4075,10 @@ function handleChildClick(e: MouseEvent, child: any, parentGroup: any) {
4036
4075
  function handleChildDblClick(e: MouseEvent, child: any, parentGroup: any) {
4037
4076
  e.stopPropagation()
4038
4077
  if (isLockedMasterElement(child)) return
4078
+ // 已在编辑该子元素时的双击 = 原生选词,保持选区不重进编辑
4079
+ if (isEditingText.value && (selectedElementId.value === child.id || editingElementId?.value === child.id)) {
4080
+ return
4081
+ }
4039
4082
  if (child.type === 'group') {
4040
4083
  const rootGroup = getRootParentGroup(child) || parentGroup
4041
4084
  selectElement(rootGroup.id, e)
@@ -4130,7 +4173,7 @@ function getElementWidthHeight(el: any): { width: number; height: number } {
4130
4173
  if (!el || typeof el !== 'object') return { width: 200, height: 100 }
4131
4174
 
4132
4175
  // 拖拽中优先从尺寸缓存中直接获取,0 次 DOM 读取与回流
4133
- if (isDragging.value && el.id && activeDragDimensions.has(el.id)) {
4176
+ if (isDragging.value && el.id && typeof activeDragDimensions !== 'undefined' && activeDragDimensions?.has(el.id)) {
4134
4177
  return activeDragDimensions.get(el.id)!
4135
4178
  }
4136
4179
 
@@ -4164,7 +4207,7 @@ function getElementWidthHeight(el: any): { width: number; height: number } {
4164
4207
  const selH = el.id === selectedElementId.value ? selectedElementHeight.value : ''
4165
4208
  const contentSig = typeof el.content === 'string' ? `${el.content.length}|${el.content.slice(0, 12)}|${el.content.slice(-12)}` : ''
4166
4209
  const dimSig = `${el.type}|${wStr}|${hStr}|${el.width || 0}|${el.height || 0}|${contentSig}|${el.latex || ''}|${st.fontSize || ''}|${st.fontFamily || ''}|${st.fontWeight || ''}|${st.lineHeight || ''}|${st.letterSpacing || ''}|${st.writingMode || ''}|${st.whiteSpace || ''}|${st.padding || ''}|${el.autoFit?.type || ''}|${el.autoFit?.fontScale || 0}|${selW}|${selH}|${logicalWidth.value}|${logicalHeight.value}|${isDragging.value ? 1 : 0}|${isResizing.value ? 1 : 0}`
4167
- const dimCached = elementDimCache.get(el)
4210
+ const dimCached = (typeof elementDimCache !== 'undefined' && elementDimCache) ? elementDimCache.get(el) : undefined
4168
4211
  if (dimCached && dimCached.sig === dimSig) return dimCached.dims
4169
4212
 
4170
4213
  if (!isWPercent && !isHPercent && wStr && hStr && wStr !== 'auto' && hStr !== 'auto') {
@@ -4207,7 +4250,7 @@ function getElementWidthHeight(el: any): { width: number; height: number } {
4207
4250
  // 关键:拖拽期间绝不查询 DOM,杜绝强制重排
4208
4251
  const dom = isDragging.value ? null : (document.getElementById(`canvas-el-${el.id}`) || document.getElementById(`editable-${el.id}`))
4209
4252
 
4210
- if (el.id === selectedElementId.value && !isResizing.value) {
4253
+ if (el.id === selectedElementId.value && !isResizing.value && el.type !== 'group') {
4211
4254
  if (selectedElementWidth.value && selectedElementWidth.value !== '0px') {
4212
4255
  const parsedW = parseFloat(selectedElementWidth.value)
4213
4256
  if (!isNaN(parsedW) && parsedW > 0) width = parsedW
@@ -4263,7 +4306,9 @@ function getElementWidthHeight(el: any): { width: number; height: number } {
4263
4306
  }
4264
4307
 
4265
4308
  const dims = { width, height }
4266
- elementDimCache.set(el, { sig: dimSig, dims })
4309
+ if (typeof elementDimCache !== 'undefined' && elementDimCache) {
4310
+ elementDimCache.set(el, { sig: dimSig, dims })
4311
+ }
4267
4312
  return dims
4268
4313
  }
4269
4314
 
@@ -4854,7 +4899,8 @@ function stopDrag(e?: any) {
4854
4899
  } finally {
4855
4900
  isDragging.value = false
4856
4901
  isCanvasDragging.value = false
4857
- activeSlideVersion.value++
4902
+ // 不再显式 bump activeSlideVersion:真实拖拽变更经下方 5000/5003 的 addHistorySnapshot
4903
+ // 落盘时统一 bump;纯点击选中无页面状态变化(选中态本就在画布 v-memo 签名里),无需全量失效
4858
4904
  activeDragDimensions.clear()
4859
4905
  hasConnectionsToSync = false
4860
4906
  dragStates = []
@@ -5004,7 +5050,7 @@ const selectedElementKeypoints = computed(() => {
5004
5050
  const el = selectedElement.value
5005
5051
  if (!el.pathFormula || !el.keypoints || !Array.isArray(el.keypoints)) return []
5006
5052
 
5007
- const formulaObj = SHAPE_PATH_FORMULAS[el.pathFormula as string]
5053
+ const formulaObj = shapeFormulas()[el.pathFormula as string]
5008
5054
  if (!formulaObj) return []
5009
5055
 
5010
5056
  const geom = getElementPixels(el)
@@ -5513,7 +5559,8 @@ function changeQuickFontSize(el: any, size: string) {
5513
5559
  }
5514
5560
  }
5515
5561
  size = formattedSize
5516
- const els = activeSlide.value.elements.filter((e: any) => selectedElementIds.value.includes(e.id))
5562
+ // 嵌套感知解析:组合内子元素也要能命中(findElementInSlide 走 elementMaps,含组内子元素)
5563
+ const els = selectedElementIds.value.map((id: string) => findElementInSlide(id)).filter((e: any) => Boolean(e))
5517
5564
  if (els.length === 0) return
5518
5565
 
5519
5566
  // 1. First check if there are selected cells (cell-level selection)
@@ -5689,7 +5736,8 @@ function changeQuickTextAlign(el: any, align: string) {
5689
5736
  }
5690
5737
 
5691
5738
  if (selectedElementIds.value.length > 1 && activeSlide.value) {
5692
- const els = activeSlide.value.elements.filter((e: any) => selectedElementIds.value.includes(e.id))
5739
+ // 嵌套感知解析:组合内子元素也要能命中(findElementInSlide 走 elementMaps,含组内子元素)
5740
+ const els = selectedElementIds.value.map((id: string) => findElementInSlide(id)).filter((e: any) => Boolean(e))
5693
5741
  els.forEach((item: any) => {
5694
5742
  if (item.style) item.style.textAlign = align
5695
5743
  syncHtmlAlign(item)
@@ -5704,7 +5752,8 @@ function changeQuickTextAlign(el: any, align: string) {
5704
5752
  function changeQuickVerticalAlign(el: any, align: string) {
5705
5753
  if (!el || !el.style) return
5706
5754
  if (selectedElementIds.value.length > 1 && activeSlide.value) {
5707
- const els = activeSlide.value.elements.filter((e: any) => selectedElementIds.value.includes(e.id))
5755
+ // 嵌套感知解析:组合内子元素也要能命中(findElementInSlide 走 elementMaps,含组内子元素)
5756
+ const els = selectedElementIds.value.map((id: string) => findElementInSlide(id)).filter((e: any) => Boolean(e))
5708
5757
  els.forEach((item: any) => {
5709
5758
  if (item.style) item.style.verticalAlign = align
5710
5759
  })
@@ -5718,7 +5767,8 @@ function toggleQuickBold(el: any) {
5718
5767
  if (!el || !el.style) return
5719
5768
  const targetState = el.style.fontWeight === 'bold' ? 'normal' : 'bold'
5720
5769
  if (selectedElementIds.value.length > 1 && activeSlide.value) {
5721
- const els = activeSlide.value.elements.filter((e: any) => selectedElementIds.value.includes(e.id))
5770
+ // 嵌套感知解析:组合内子元素也要能命中(findElementInSlide 走 elementMaps,含组内子元素)
5771
+ const els = selectedElementIds.value.map((id: string) => findElementInSlide(id)).filter((e: any) => Boolean(e))
5722
5772
  els.forEach((item: any) => {
5723
5773
  if (item.style) item.style.fontWeight = targetState
5724
5774
  })
@@ -5732,7 +5782,8 @@ function toggleQuickItalic(el: any) {
5732
5782
  if (!el || !el.style) return
5733
5783
  const targetState = el.style.fontStyle === 'italic' ? 'normal' : 'italic'
5734
5784
  if (selectedElementIds.value.length > 1 && activeSlide.value) {
5735
- const els = activeSlide.value.elements.filter((e: any) => selectedElementIds.value.includes(e.id))
5785
+ // 嵌套感知解析:组合内子元素也要能命中(findElementInSlide 走 elementMaps,含组内子元素)
5786
+ const els = selectedElementIds.value.map((id: string) => findElementInSlide(id)).filter((e: any) => Boolean(e))
5736
5787
  els.forEach((item: any) => {
5737
5788
  if (item.style) item.style.fontStyle = targetState
5738
5789
  })
@@ -5761,7 +5812,8 @@ function toggleQuickUnderline(el: any) {
5761
5812
  const hasUnderline = (el.style.textDecoration || '').includes('underline')
5762
5813
  const decorator = 'underline'
5763
5814
  if (selectedElementIds.value.length > 1 && activeSlide.value) {
5764
- const els = activeSlide.value.elements.filter((e: any) => selectedElementIds.value.includes(e.id))
5815
+ // 嵌套感知解析:组合内子元素也要能命中(findElementInSlide 走 elementMaps,含组内子元素)
5816
+ const els = selectedElementIds.value.map((id: string) => findElementInSlide(id)).filter((e: any) => Boolean(e))
5765
5817
  els.forEach((item: any) => {
5766
5818
  if (item.style) {
5767
5819
  const current = item.style.textDecoration || ''
@@ -5787,7 +5839,8 @@ function toggleQuickStrikethrough(el: any) {
5787
5839
  const hasLineThrough = (el.style.textDecoration || '').includes('line-through')
5788
5840
  const decorator = 'line-through'
5789
5841
  if (selectedElementIds.value.length > 1 && activeSlide.value) {
5790
- const els = activeSlide.value.elements.filter((e: any) => selectedElementIds.value.includes(e.id))
5842
+ // 嵌套感知解析:组合内子元素也要能命中(findElementInSlide 走 elementMaps,含组内子元素)
5843
+ const els = selectedElementIds.value.map((id: string) => findElementInSlide(id)).filter((e: any) => Boolean(e))
5791
5844
  els.forEach((item: any) => {
5792
5845
  if (item.style) {
5793
5846
  const current = item.style.textDecoration || ''
@@ -7232,16 +7285,13 @@ function handleGlobalCopy(e: ClipboardEvent) {
7232
7285
 
7233
7286
  if (!activeSlide.value) return
7234
7287
 
7235
- const copied = activeSlide.value.elements
7236
- .filter(el => selectedElementIds.value.includes(el.id))
7237
- .map(el => JSON.parse(JSON.stringify(el)))
7238
-
7239
- clipboard.value = copied
7240
- slideClipboard.value = null
7241
- e.clipboardData?.setData('text/plain', JSON.stringify(copied))
7242
- e.preventDefault()
7243
- e.stopPropagation()
7244
- notify(`已复制 ${copied.length} 个元素`)
7288
+ const payload = copySelectedElements()
7289
+ if (payload) {
7290
+ e.clipboardData?.setData('text/plain', JSON.stringify(payload))
7291
+ e.preventDefault()
7292
+ e.stopPropagation()
7293
+ notify(`已复制 ${payload.data.length} 个元素`)
7294
+ }
7245
7295
  }
7246
7296
 
7247
7297
  function handleGlobalCut(e: ClipboardEvent) {
@@ -7278,22 +7328,19 @@ function handleGlobalCut(e: ClipboardEvent) {
7278
7328
 
7279
7329
  if (!activeSlide.value) return
7280
7330
 
7281
- const copied = activeSlide.value.elements
7282
- .filter(el => selectedElementIds.value.includes(el.id))
7283
- .map(el => JSON.parse(JSON.stringify(el)))
7284
-
7285
- clipboard.value = copied
7286
- slideClipboard.value = null
7287
- e.clipboardData?.setData('text/plain', JSON.stringify(copied))
7288
- e.preventDefault()
7289
- e.stopPropagation()
7331
+ const payload = copySelectedElements()
7332
+ if (payload) {
7333
+ e.clipboardData?.setData('text/plain', JSON.stringify(payload))
7334
+ e.preventDefault()
7335
+ e.stopPropagation()
7290
7336
 
7291
- selectedElementIds.value.forEach(id => {
7292
- deleteElement(id)
7293
- })
7294
- selectedElementIds.value = []
7295
- selectedElementId.value = null
7296
- notify(`已剪切 ${copied.length} 个元素`)
7337
+ selectedElementIds.value.forEach(id => {
7338
+ deleteElement(id)
7339
+ })
7340
+ selectedElementIds.value = []
7341
+ selectedElementId.value = null
7342
+ notify(`已剪切 ${payload.data.length} 个元素`)
7343
+ }
7297
7344
  }
7298
7345
 
7299
7346
  function extractImagesAndTextFromRawString(rawStr: string): { imageSources: string[]; remainingText: string } {
@@ -7454,6 +7501,14 @@ async function handleGlobalPaste(e: ClipboardEvent) {
7454
7501
  pasteSlideData(data.data, activeIndex.value)
7455
7502
  return
7456
7503
  }
7504
+ if (data && data.type === 'elements' && Array.isArray(data.data) && data.data.length > 0) {
7505
+ e.preventDefault()
7506
+ e.stopPropagation()
7507
+ clipboard.value = data.data
7508
+ slideClipboard.value = null
7509
+ pasteElementsData(data.data, { sourceWidth: data.sourceWidth, sourceHeight: data.sourceHeight })
7510
+ return
7511
+ }
7457
7512
  if (Array.isArray(data) && data.length > 0 && data[0].type && data[0].style) {
7458
7513
  e.preventDefault()
7459
7514
  e.stopPropagation()
@@ -7835,6 +7890,8 @@ async function handleGlobalPaste(e: ClipboardEvent) {
7835
7890
  v-else-if="replica.el.type === 'group'"
7836
7891
  :element="replica.el"
7837
7892
  :active-slide="activeSlide"
7893
+ :parent-width-px="logicalWidth"
7894
+ :parent-height-px="logicalHeight"
7838
7895
  :is-selected="false"
7839
7896
  :selected-element-id="null"
7840
7897
  :selected-child-id="null"
@@ -8968,6 +9025,11 @@ async function handleGlobalPaste(e: ClipboardEvent) {
8968
9025
  outline: none !important;
8969
9026
  }
8970
9027
 
9028
+ /* 焦点(光标)所在的唯一选中格:不显示选中蒙层 */
9029
+ :deep(td.selected-cell.focused-cell) {
9030
+ background-image: none !important;
9031
+ }
9032
+
8971
9033
  /* ================= 版式实时切换测试控制面板样式 ================= */
8972
9034
  .layout-test-floating-panel {
8973
9035
  position: absolute;