jvs-ppt-ui 0.0.8 → 0.0.10

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 (34) hide show
  1. package/README.md +60 -2
  2. package/dist/lib/jvs-ppt-ui.es.js +22404 -21454
  3. package/dist/lib/jvs-ppt-ui.umd.js +102 -101
  4. package/dist/lib/style.css +1 -1
  5. package/package.json +1 -1
  6. package/src/App.vue +53 -53
  7. package/src/components/AIPptDialog.vue +2 -0
  8. package/src/components/CanvasArea.vue +164 -72
  9. package/src/components/Header.vue +374 -144
  10. package/src/components/PptEditor.vue +33 -15
  11. package/src/components/PresentationMode.vue +24 -4
  12. package/src/components/SidebarRight.vue +8 -1
  13. package/src/components/SvgChart.vue +5 -2
  14. package/src/components/TemplateGallery.vue +1321 -866
  15. package/src/components/Toolbar.vue +244 -22
  16. package/src/components/canvas/CanvasTransformHandles.vue +5 -1
  17. package/src/components/canvas/elements/CanvasShapeElement.vue +98 -19
  18. package/src/components/canvas/elements/CanvasTextElement.vue +39 -9
  19. package/src/components/canvas/textSelectionOverlay.ts +305 -0
  20. package/src/composables/canvas/useCanvasAlignment.ts +286 -238
  21. package/src/composables/canvas/useCanvasContextMenu.ts +53 -22
  22. package/src/composables/canvas/useCanvasFormatting.ts +35 -12
  23. package/src/composables/canvas/useCanvasTable.ts +52 -24
  24. package/src/composables/canvas/useCanvasText.ts +12 -0
  25. package/src/composables/canvas/useCanvasTransform.ts +15 -2
  26. package/src/composables/sidebar/useSidebarElementTransform.ts +5 -5
  27. package/src/composables/sidebar/useSidebarShapeFormat.ts +2 -2
  28. package/src/composables/useSlides.ts +495 -132
  29. package/src/style.css +88 -0
  30. package/src/types/index.ts +8 -0
  31. package/src/utils/line.ts +274 -312
  32. package/src/utils/pptxShapes.ts +1 -1
  33. package/src/utils/pptxShapesLazy.ts +39 -0
  34. package/src/utils/svgPathParser.ts +12 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jvs-ppt-ui",
3
3
  "private": false,
4
- "version": "0.0.8",
4
+ "version": "0.0.10",
5
5
  "type": "module",
6
6
  "main": "./dist/lib/jvs-ppt-ui.umd.js",
7
7
  "module": "./dist/lib/jvs-ppt-ui.es.js",
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
@@ -3086,6 +3112,9 @@ function stopCreationDrag() {
3086
3112
  }
3087
3113
 
3088
3114
  activeSlide.value.elements.push(createdElement)
3115
+ if (createdElement.type === 'line' && (createdElement.startConnection || createdElement.endConnection)) {
3116
+ syncSlideConnections(activeSlide.value, logicalWidth.value, logicalHeight.value, [createdElement.id])
3117
+ }
3089
3118
  selectedElementId.value = createdElement.id
3090
3119
  selectedElementIds.value = [createdElement.id]
3091
3120
  justCreatedElement = true
@@ -3628,12 +3657,25 @@ function handleElementDblClick(e: MouseEvent, el: any) {
3628
3657
  target = activeSelectedEl
3629
3658
  }
3630
3659
  }
3660
+ // 已在编辑该元素时的双击 = 原生选词,保持选区不重进编辑
3661
+ // (重跑 startEditingShapeText 会覆盖 initialTextContent 变更基线)
3662
+ if (isEditingText.value && (selectedElementId.value === target.id || editingElementId?.value === target.id)) {
3663
+ return
3664
+ }
3631
3665
  if (isImagePlaceholder(target)) {
3632
3666
  triggerPlaceholderImageUpload(target.id)
3633
3667
  } else if (target.type === 'shape') {
3634
3668
  startEditingShapeText(target, e)
3635
3669
  } else if (target.type === 'text') {
3636
- 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
+ }
3637
3679
  }
3638
3680
  }
3639
3681
 
@@ -4033,6 +4075,10 @@ function handleChildClick(e: MouseEvent, child: any, parentGroup: any) {
4033
4075
  function handleChildDblClick(e: MouseEvent, child: any, parentGroup: any) {
4034
4076
  e.stopPropagation()
4035
4077
  if (isLockedMasterElement(child)) return
4078
+ // 已在编辑该子元素时的双击 = 原生选词,保持选区不重进编辑
4079
+ if (isEditingText.value && (selectedElementId.value === child.id || editingElementId?.value === child.id)) {
4080
+ return
4081
+ }
4036
4082
  if (child.type === 'group') {
4037
4083
  const rootGroup = getRootParentGroup(child) || parentGroup
4038
4084
  selectElement(rootGroup.id, e)
@@ -4127,7 +4173,7 @@ function getElementWidthHeight(el: any): { width: number; height: number } {
4127
4173
  if (!el || typeof el !== 'object') return { width: 200, height: 100 }
4128
4174
 
4129
4175
  // 拖拽中优先从尺寸缓存中直接获取,0 次 DOM 读取与回流
4130
- if (isDragging.value && el.id && activeDragDimensions.has(el.id)) {
4176
+ if (isDragging.value && el.id && typeof activeDragDimensions !== 'undefined' && activeDragDimensions?.has(el.id)) {
4131
4177
  return activeDragDimensions.get(el.id)!
4132
4178
  }
4133
4179
 
@@ -4161,7 +4207,7 @@ function getElementWidthHeight(el: any): { width: number; height: number } {
4161
4207
  const selH = el.id === selectedElementId.value ? selectedElementHeight.value : ''
4162
4208
  const contentSig = typeof el.content === 'string' ? `${el.content.length}|${el.content.slice(0, 12)}|${el.content.slice(-12)}` : ''
4163
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}`
4164
- const dimCached = elementDimCache.get(el)
4210
+ const dimCached = (typeof elementDimCache !== 'undefined' && elementDimCache) ? elementDimCache.get(el) : undefined
4165
4211
  if (dimCached && dimCached.sig === dimSig) return dimCached.dims
4166
4212
 
4167
4213
  if (!isWPercent && !isHPercent && wStr && hStr && wStr !== 'auto' && hStr !== 'auto') {
@@ -4204,7 +4250,7 @@ function getElementWidthHeight(el: any): { width: number; height: number } {
4204
4250
  // 关键:拖拽期间绝不查询 DOM,杜绝强制重排
4205
4251
  const dom = isDragging.value ? null : (document.getElementById(`canvas-el-${el.id}`) || document.getElementById(`editable-${el.id}`))
4206
4252
 
4207
- if (el.id === selectedElementId.value && !isResizing.value) {
4253
+ if (el.id === selectedElementId.value && !isResizing.value && el.type !== 'group') {
4208
4254
  if (selectedElementWidth.value && selectedElementWidth.value !== '0px') {
4209
4255
  const parsedW = parseFloat(selectedElementWidth.value)
4210
4256
  if (!isNaN(parsedW) && parsedW > 0) width = parsedW
@@ -4260,7 +4306,9 @@ function getElementWidthHeight(el: any): { width: number; height: number } {
4260
4306
  }
4261
4307
 
4262
4308
  const dims = { width, height }
4263
- elementDimCache.set(el, { sig: dimSig, dims })
4309
+ if (typeof elementDimCache !== 'undefined' && elementDimCache) {
4310
+ elementDimCache.set(el, { sig: dimSig, dims })
4311
+ }
4264
4312
  return dims
4265
4313
  }
4266
4314
 
@@ -4575,14 +4623,37 @@ function startDrag(e: MouseEvent, el: any) {
4575
4623
  : selectedEls
4576
4624
 
4577
4625
  // 检查是否有连线需要同步,避免无连线时每帧循环
4578
- const selIds = new Set(selectedElementIds.value)
4579
- hasConnectionsToSync = (activeSlide.value?.elements || []).some((it: any) =>
4580
- it && it.type === 'line' && (
4581
- selIds.has(it.id) ||
4582
- (it.startConnection && selIds.has(it.startConnection.id)) ||
4583
- (it.endConnection && selIds.has(it.endConnection.id))
4584
- )
4585
- )
4626
+ const selIds = new Set<string>(selectedElementIds.value)
4627
+ if (Array.isArray(targetIds)) targetIds.forEach(id => selIds.add(id))
4628
+ dragTargetEls.forEach((item: any) => {
4629
+ if (item?.id) selIds.add(item.id)
4630
+ if (item?.type === 'group' && Array.isArray(item.elements)) {
4631
+ const addChildren = (children: any[]) => {
4632
+ children.forEach(ch => {
4633
+ if (ch?.id) selIds.add(ch.id)
4634
+ if (ch?.type === 'group' && Array.isArray(ch.elements)) addChildren(ch.elements)
4635
+ })
4636
+ }
4637
+ addChildren(item.elements)
4638
+ }
4639
+ })
4640
+ const checkHasConnection = (elements: any[]): boolean => {
4641
+ for (const it of elements) {
4642
+ if (!it) continue
4643
+ if (it.type === 'line' && (
4644
+ selIds.has(it.id) ||
4645
+ (it.startConnection && selIds.has(it.startConnection.id)) ||
4646
+ (it.endConnection && selIds.has(it.endConnection.id))
4647
+ )) {
4648
+ return true
4649
+ }
4650
+ if (it.type === 'group' && Array.isArray(it.elements)) {
4651
+ if (checkHasConnection(it.elements)) return true
4652
+ }
4653
+ }
4654
+ return false
4655
+ }
4656
+ hasConnectionsToSync = checkHasConnection(activeSlide.value?.elements || [])
4586
4657
 
4587
4658
  // Record drag states
4588
4659
  dragStates = dragTargetEls.map((item: any) => {
@@ -4828,7 +4899,8 @@ function stopDrag(e?: any) {
4828
4899
  } finally {
4829
4900
  isDragging.value = false
4830
4901
  isCanvasDragging.value = false
4831
- activeSlideVersion.value++
4902
+ // 不再显式 bump activeSlideVersion:真实拖拽变更经下方 5000/5003 的 addHistorySnapshot
4903
+ // 落盘时统一 bump;纯点击选中无页面状态变化(选中态本就在画布 v-memo 签名里),无需全量失效
4832
4904
  activeDragDimensions.clear()
4833
4905
  hasConnectionsToSync = false
4834
4906
  dragStates = []
@@ -4978,7 +5050,7 @@ const selectedElementKeypoints = computed(() => {
4978
5050
  const el = selectedElement.value
4979
5051
  if (!el.pathFormula || !el.keypoints || !Array.isArray(el.keypoints)) return []
4980
5052
 
4981
- const formulaObj = SHAPE_PATH_FORMULAS[el.pathFormula as string]
5053
+ const formulaObj = shapeFormulas()[el.pathFormula as string]
4982
5054
  if (!formulaObj) return []
4983
5055
 
4984
5056
  const geom = getElementPixels(el)
@@ -5487,7 +5559,8 @@ function changeQuickFontSize(el: any, size: string) {
5487
5559
  }
5488
5560
  }
5489
5561
  size = formattedSize
5490
- 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))
5491
5564
  if (els.length === 0) return
5492
5565
 
5493
5566
  // 1. First check if there are selected cells (cell-level selection)
@@ -5663,7 +5736,8 @@ function changeQuickTextAlign(el: any, align: string) {
5663
5736
  }
5664
5737
 
5665
5738
  if (selectedElementIds.value.length > 1 && activeSlide.value) {
5666
- 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))
5667
5741
  els.forEach((item: any) => {
5668
5742
  if (item.style) item.style.textAlign = align
5669
5743
  syncHtmlAlign(item)
@@ -5678,7 +5752,8 @@ function changeQuickTextAlign(el: any, align: string) {
5678
5752
  function changeQuickVerticalAlign(el: any, align: string) {
5679
5753
  if (!el || !el.style) return
5680
5754
  if (selectedElementIds.value.length > 1 && activeSlide.value) {
5681
- 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))
5682
5757
  els.forEach((item: any) => {
5683
5758
  if (item.style) item.style.verticalAlign = align
5684
5759
  })
@@ -5692,7 +5767,8 @@ function toggleQuickBold(el: any) {
5692
5767
  if (!el || !el.style) return
5693
5768
  const targetState = el.style.fontWeight === 'bold' ? 'normal' : 'bold'
5694
5769
  if (selectedElementIds.value.length > 1 && activeSlide.value) {
5695
- 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))
5696
5772
  els.forEach((item: any) => {
5697
5773
  if (item.style) item.style.fontWeight = targetState
5698
5774
  })
@@ -5706,7 +5782,8 @@ function toggleQuickItalic(el: any) {
5706
5782
  if (!el || !el.style) return
5707
5783
  const targetState = el.style.fontStyle === 'italic' ? 'normal' : 'italic'
5708
5784
  if (selectedElementIds.value.length > 1 && activeSlide.value) {
5709
- 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))
5710
5787
  els.forEach((item: any) => {
5711
5788
  if (item.style) item.style.fontStyle = targetState
5712
5789
  })
@@ -5735,7 +5812,8 @@ function toggleQuickUnderline(el: any) {
5735
5812
  const hasUnderline = (el.style.textDecoration || '').includes('underline')
5736
5813
  const decorator = 'underline'
5737
5814
  if (selectedElementIds.value.length > 1 && activeSlide.value) {
5738
- 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))
5739
5817
  els.forEach((item: any) => {
5740
5818
  if (item.style) {
5741
5819
  const current = item.style.textDecoration || ''
@@ -5761,7 +5839,8 @@ function toggleQuickStrikethrough(el: any) {
5761
5839
  const hasLineThrough = (el.style.textDecoration || '').includes('line-through')
5762
5840
  const decorator = 'line-through'
5763
5841
  if (selectedElementIds.value.length > 1 && activeSlide.value) {
5764
- 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))
5765
5844
  els.forEach((item: any) => {
5766
5845
  if (item.style) {
5767
5846
  const current = item.style.textDecoration || ''
@@ -7206,16 +7285,13 @@ function handleGlobalCopy(e: ClipboardEvent) {
7206
7285
 
7207
7286
  if (!activeSlide.value) return
7208
7287
 
7209
- const copied = activeSlide.value.elements
7210
- .filter(el => selectedElementIds.value.includes(el.id))
7211
- .map(el => JSON.parse(JSON.stringify(el)))
7212
-
7213
- clipboard.value = copied
7214
- slideClipboard.value = null
7215
- e.clipboardData?.setData('text/plain', JSON.stringify(copied))
7216
- e.preventDefault()
7217
- e.stopPropagation()
7218
- 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
+ }
7219
7295
  }
7220
7296
 
7221
7297
  function handleGlobalCut(e: ClipboardEvent) {
@@ -7252,22 +7328,19 @@ function handleGlobalCut(e: ClipboardEvent) {
7252
7328
 
7253
7329
  if (!activeSlide.value) return
7254
7330
 
7255
- const copied = activeSlide.value.elements
7256
- .filter(el => selectedElementIds.value.includes(el.id))
7257
- .map(el => JSON.parse(JSON.stringify(el)))
7258
-
7259
- clipboard.value = copied
7260
- slideClipboard.value = null
7261
- e.clipboardData?.setData('text/plain', JSON.stringify(copied))
7262
- e.preventDefault()
7263
- e.stopPropagation()
7331
+ const payload = copySelectedElements()
7332
+ if (payload) {
7333
+ e.clipboardData?.setData('text/plain', JSON.stringify(payload))
7334
+ e.preventDefault()
7335
+ e.stopPropagation()
7264
7336
 
7265
- selectedElementIds.value.forEach(id => {
7266
- deleteElement(id)
7267
- })
7268
- selectedElementIds.value = []
7269
- selectedElementId.value = null
7270
- 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
+ }
7271
7344
  }
7272
7345
 
7273
7346
  function extractImagesAndTextFromRawString(rawStr: string): { imageSources: string[]; remainingText: string } {
@@ -7428,6 +7501,14 @@ async function handleGlobalPaste(e: ClipboardEvent) {
7428
7501
  pasteSlideData(data.data, activeIndex.value)
7429
7502
  return
7430
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
+ }
7431
7512
  if (Array.isArray(data) && data.length > 0 && data[0].type && data[0].style) {
7432
7513
  e.preventDefault()
7433
7514
  e.stopPropagation()
@@ -7809,6 +7890,8 @@ async function handleGlobalPaste(e: ClipboardEvent) {
7809
7890
  v-else-if="replica.el.type === 'group'"
7810
7891
  :element="replica.el"
7811
7892
  :active-slide="activeSlide"
7893
+ :parent-width-px="logicalWidth"
7894
+ :parent-height-px="logicalHeight"
7812
7895
  :is-selected="false"
7813
7896
  :selected-element-id="null"
7814
7897
  :selected-child-id="null"
@@ -7920,8 +8003,12 @@ async function handleGlobalPaste(e: ClipboardEvent) {
7920
8003
  v-show="el.visible !== false"
7921
8004
  v-memo="[
7922
8005
  (isDragging || isCanvasDragging)
7923
- ? (selectedElementIds.includes(el.id) ? `${el.style?.left}_${el.style?.top}_${el.style?.width}_${el.style?.height}_${el.style?.rotate}` : 'frozen')
7924
- : `${el.id}_${el.style?.left}_${el.style?.top}_${el.style?.width}_${el.style?.height}_${el.style?.rotate}_${selectedElementIds.includes(el.id)}_${selectedElementId === el.id}_${isEditingText}_${activeTab}_${activeSlideVersion}`
8006
+ ? (selectedElementIds.includes(el.id)
8007
+ ? `${el.style?.left}_${el.style?.top}_${el.style?.width}_${el.style?.height}_${el.style?.rotate}`
8008
+ : (el.type === 'line'
8009
+ ? `${el.style?.left}_${el.style?.top}_${el.style?.width}_${el.style?.height}_${el.start?.join(',')}_${el.end?.join(',')}_${el.broken?.join(',')}_${el.broken2?.join(',')}_${el.curve?.join(',')}`
8010
+ : 'frozen'))
8011
+ : `${el.id}_${el.style?.left}_${el.style?.top}_${el.style?.width}_${el.style?.height}_${el.style?.rotate}_${el.type === 'line' ? `${el.start?.join(',')}_${el.end?.join(',')}_${el.broken?.join(',')}_${el.broken2?.join(',')}_${el.curve?.join(',')}_` : ''}${selectedElementIds.includes(el.id)}_${selectedElementId === el.id}_${isEditingText}_${activeTab}_${activeSlideVersion}`
7925
8012
  ]"
7926
8013
  :class="{
7927
8014
  selected: selectedElementIds.includes(el.id),
@@ -8938,6 +9025,11 @@ async function handleGlobalPaste(e: ClipboardEvent) {
8938
9025
  outline: none !important;
8939
9026
  }
8940
9027
 
9028
+ /* 焦点(光标)所在的唯一选中格:不显示选中蒙层 */
9029
+ :deep(td.selected-cell.focused-cell) {
9030
+ background-image: none !important;
9031
+ }
9032
+
8941
9033
  /* ================= 版式实时切换测试控制面板样式 ================= */
8942
9034
  .layout-test-floating-panel {
8943
9035
  position: absolute;