jvs-ppt-ui 0.0.9 → 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 (33) 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 +1 -1
  6. package/src/App.vue +53 -53
  7. package/src/components/AIPptDialog.vue +2 -0
  8. package/src/components/CanvasArea.vue +124 -62
  9. package/src/components/Header.vue +364 -142
  10. package/src/components/PptEditor.vue +26 -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 +243 -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 +2 -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 +482 -127
  29. package/src/style.css +88 -0
  30. package/src/types/index.ts +4 -0
  31. package/src/utils/pptxShapes.ts +1 -1
  32. package/src/utils/pptxShapesLazy.ts +39 -0
  33. package/src/utils/svgPathParser.ts +12 -1
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { ref, watch } from 'vue'
2
+ import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
3
3
  import { useSlides } from '../composables/useSlides'
4
4
  import { SHAPE_LIST } from '../utils/shapes'
5
5
  import type { ShapePoolItem } from '../utils/shapes'
@@ -9,6 +9,75 @@ import ChartTypeSelector from './ChartTypeSelector.vue'
9
9
  import TemplateGallery from './TemplateGallery.vue'
10
10
  import { type SlideTextType, TEXT_TYPE_LIST } from '../utils/textTypes'
11
11
 
12
+ const props = withDefaults(defineProps<{
13
+ toolbarMode?: 'auto' | 'more' | 'full'
14
+ }>(), {
15
+ toolbarMode: 'auto'
16
+ })
17
+
18
+ const toolbarRootRef = ref<HTMLElement | null>(null)
19
+ const isNarrowLayout = ref(false)
20
+ const morePopoverVisible = ref(false)
21
+
22
+ const isMoreMode = computed(() => {
23
+ if (props.toolbarMode === 'more') return true
24
+ if (props.toolbarMode === 'full') return false
25
+ return isNarrowLayout.value
26
+ })
27
+
28
+ let resizeObserver: ResizeObserver | null = null
29
+
30
+ onMounted(() => {
31
+ if (typeof ResizeObserver !== 'undefined' && toolbarRootRef.value) {
32
+ const parentContainer = toolbarRootRef.value.closest('.header-center-tools') || toolbarRootRef.value.parentElement
33
+ if (parentContainer) {
34
+ resizeObserver = new ResizeObserver((entries) => {
35
+ for (const entry of entries) {
36
+ // 工具栏可用宽度小于 640px 时(如在 700px~900px 弹窗或小屏幕下),自动切换为“更多功能”折叠收纳模式
37
+ isNarrowLayout.value = entry.contentRect.width < 640
38
+ }
39
+ })
40
+ resizeObserver.observe(parentContainer)
41
+ }
42
+ }
43
+ const checkFallback = () => {
44
+ isNarrowLayout.value = window.innerWidth < 1150
45
+ }
46
+ checkFallback()
47
+ window.addEventListener('resize', checkFallback)
48
+ })
49
+
50
+ onBeforeUnmount(() => {
51
+ resizeObserver?.disconnect()
52
+ })
53
+
54
+ function handleMoreAction(type: string) {
55
+ morePopoverVisible.value = false
56
+ if (type === 'image') {
57
+ triggerLocalUpload()
58
+ } else if (type === 'media') {
59
+ mediaPopoverVisible.value = true
60
+ } else if (type === 'table') {
61
+ tablePopoverVisible.value = true
62
+ } else if (type === 'chart') {
63
+ chartPopoverVisible.value = true
64
+ } else if (type === 'material') {
65
+ materialPopoverVisible.value = true
66
+ } else if (type === 'format') {
67
+ activeTab.value = (activeTab.value === 'format' || activeTab.value === 'design' || activeTab.value === 'multi-style' || activeTab.value === 'multi-position')
68
+ ? null
69
+ : (selectedElementIds.value.length > 1 ? 'multi-style' : (selectedElementId.value ? 'format' : 'design'))
70
+ isOpenedFromFloatToolbar.value = false
71
+ } else if (type === 'animation') {
72
+ activeTab.value = (activeTab.value === 'animation' || activeTab.value === 'transition')
73
+ ? null
74
+ : (selectedElementId.value ? 'animation' : 'transition')
75
+ isOpenedFromFloatToolbar.value = false
76
+ } else if (type === 'ai') {
77
+ aiReplaceCurrentSlide()
78
+ }
79
+ }
80
+
12
81
  const {
13
82
  addElement, addTextByType, pendingTextType, zoomRatio, zoomIn, zoomOut, resetZoom, notify, activeTool, pendingShapeSubtype, pendingShapeViewBox, pendingLineContent, undo, redo, canUndo, canRedo,
14
83
  activeSlide, getActiveSlideThemeRecipe, formulaDialogVisible, selectedElementId, selectedElementIds,
@@ -709,7 +778,7 @@ function cancelInsertMedia() {
709
778
  </div>
710
779
  </div>
711
780
 
712
- <div v-else class="app-toolbar">
781
+ <div v-else class="app-toolbar" ref="toolbarRootRef">
713
782
  <!-- Wrapped toolbar interactive contents -->
714
783
  <div class="toolbar-content toolbar-inner">
715
784
 
@@ -988,19 +1057,94 @@ function cancelInsertMedia() {
988
1057
  </template>
989
1058
  </el-dropdown>
990
1059
 
991
- <!-- Image Dropdown -->
992
- <input type="file" ref="fileInputRef" accept="image/*" style="display: none" @change="handleFileChange" />
993
- <el-dropdown trigger="click" @command="handleImageCommand" popper-class="image-dropdown-popper"
994
- placement="bottom-start" @visible-change="imageDropdownVisible = $event">
995
- <div class="toolbar-btn-stacked" :class="{ 'btn-active': imageDropdownVisible }" title="插入图片"
996
- @mouseenter="hoveredButton = 'image'" @mouseleave="hoveredButton = null"
997
- :data-tooltip="locale === 'zh' ? '插入图片' : 'Insert Image'">
998
- <div class="btn-trigger-inner">
999
- <svg viewBox="0 0 1024 1024" class="toolbar-btn-icon toolbar-btn-icon-no-margin">
1000
- <use
1001
- :xlink:href="(imageDropdownVisible || hoveredButton === 'image') ? '#icon-tupian1' : '#icon-tupian1'">
1002
- </use>
1003
- </svg>
1060
+ <!-- More Operations Popover (在窄屏或 more 模式下收纳扩展功能) -->
1061
+ <el-popover
1062
+ v-if="isMoreMode"
1063
+ trigger="click"
1064
+ placement="bottom-start"
1065
+ :width="280"
1066
+ popper-class="toolbar-more-popover"
1067
+ v-model:visible="morePopoverVisible"
1068
+ :show-arrow="false"
1069
+ >
1070
+ <template #reference>
1071
+ <div
1072
+ class="toolbar-btn-stacked toolbar-more-btn"
1073
+ :class="{ 'btn-active': morePopoverVisible }"
1074
+ title="更多功能模块"
1075
+ :data-tooltip="locale === 'zh' ? '更多功能模块' : 'More Features'"
1076
+ >
1077
+ <div class="btn-trigger-inner">
1078
+ <svg viewBox="0 0 1024 1024" class="toolbar-btn-icon toolbar-btn-icon-no-margin" style="width: 18px; height: 18px;">
1079
+ <path d="M213.333 512m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0Z" fill="currentColor"/>
1080
+ <path d="M512 512m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0Z" fill="currentColor"/>
1081
+ <path d="M810.667 512m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0Z" fill="currentColor"/>
1082
+ </svg>
1083
+ </div>
1084
+ <span class="btn-label">{{ locale === 'zh' ? '更多' : 'More' }}</span>
1085
+ </div>
1086
+ </template>
1087
+
1088
+ <div class="toolbar-more-panel">
1089
+ <div class="toolbar-more-section-title">插入对象</div>
1090
+ <div class="toolbar-more-grid">
1091
+ <div class="toolbar-more-item" @click="handleMoreAction('image')" title="上传图片">
1092
+ <svg viewBox="0 0 1024 1024" class="toolbar-more-icon"><use xlink:href="#icon-tupian1"/></svg>
1093
+ <span>图片</span>
1094
+ </div>
1095
+ <div class="toolbar-more-item" @click="handleMoreAction('media')" title="插入音频/视频">
1096
+ <svg viewBox="0 0 1024 1024" class="toolbar-more-icon"><use xlink:href="#icon-meiti1"/></svg>
1097
+ <span>音视频</span>
1098
+ </div>
1099
+ <div class="toolbar-more-item" @click="handleMoreAction('table')" title="插入表格">
1100
+ <svg viewBox="0 0 1024 1024" class="toolbar-more-icon"><use xlink:href="#icon-biaoge1"/></svg>
1101
+ <span>表格</span>
1102
+ </div>
1103
+ <div class="toolbar-more-item" @click="handleMoreAction('chart')" title="插入图表">
1104
+ <svg viewBox="0 0 1024 1024" class="toolbar-more-icon"><use xlink:href="#icon-tubiao1"/></svg>
1105
+ <span>图表</span>
1106
+ </div>
1107
+ <div v-if="isMaterialEnabled" class="toolbar-more-item" @click="handleMoreAction('material')" title="素材库">
1108
+ <svg viewBox="0 0 1024 1024" class="toolbar-more-icon"><use xlink:href="#icon-sucaiku"/></svg>
1109
+ <span>素材库</span>
1110
+ </div>
1111
+ </div>
1112
+
1113
+ <div class="toolbar-more-divider"></div>
1114
+
1115
+ <div class="toolbar-more-section-title">设计与效果</div>
1116
+ <div class="toolbar-more-grid">
1117
+ <div class="toolbar-more-item" @click="handleMoreAction('format')" title="设计面板">
1118
+ <svg viewBox="0 0 1024 1024" class="toolbar-more-icon"><use xlink:href="#icon-geshi1"/></svg>
1119
+ <span>设计</span>
1120
+ </div>
1121
+ <div class="toolbar-more-item" @click="handleMoreAction('animation')" title="动画与切换">
1122
+ <svg viewBox="0 0 1024 1024" class="toolbar-more-icon"><use xlink:href="#icon-donghua1"/></svg>
1123
+ <span>动画</span>
1124
+ </div>
1125
+ <div v-if="isAiBeautifyEnabled" class="toolbar-more-item toolbar-more-ai" @click="handleMoreAction('ai')" title="一键美化">
1126
+ <svg viewBox="0 0 1024 1024" class="toolbar-more-icon" style="color: #ec4899;"><use xlink:href="#icon-yijianmeihua"/></svg>
1127
+ <span style="color: #ec4899; font-weight: 600;">一键美化</span>
1128
+ </div>
1129
+ </div>
1130
+ </div>
1131
+ </el-popover>
1132
+
1133
+ <!-- 宽屏展开模式:依次平铺后续扩展按钮 -->
1134
+ <template v-if="!isMoreMode">
1135
+ <!-- Image Dropdown -->
1136
+ <input type="file" ref="fileInputRef" accept="image/*" style="display: none" @change="handleFileChange" />
1137
+ <el-dropdown trigger="click" @command="handleImageCommand" popper-class="image-dropdown-popper"
1138
+ placement="bottom-start" @visible-change="imageDropdownVisible = $event">
1139
+ <div class="toolbar-btn-stacked" :class="{ 'btn-active': imageDropdownVisible }" title="插入图片"
1140
+ @mouseenter="hoveredButton = 'image'" @mouseleave="hoveredButton = null"
1141
+ :data-tooltip="locale === 'zh' ? '插入图片' : 'Insert Image'">
1142
+ <div class="btn-trigger-inner">
1143
+ <svg viewBox="0 0 1024 1024" class="toolbar-btn-icon toolbar-btn-icon-no-margin">
1144
+ <use
1145
+ :xlink:href="(imageDropdownVisible || hoveredButton === 'image') ? '#icon-tupian1' : '#icon-tupian1'">
1146
+ </use>
1147
+ </svg>
1004
1148
  <svg viewBox="0 0 24 24" width="8" height="8" class="btn-arrow-icon">
1005
1149
  <path d="M6 9l6 6 6-6" />
1006
1150
  </svg>
@@ -1172,7 +1316,8 @@ function cancelInsertMedia() {
1172
1316
  </el-popover>
1173
1317
 
1174
1318
  <!-- Material (素材) Popover (受控于 isMaterialEnabled) -->
1175
- <el-popover v-if="isMaterialEnabled" trigger="click" placement="bottom" :width="720" popper-class="material-popover-popper"
1319
+ <el-popover v-if="isMaterialEnabled" trigger="click" placement="bottom" :width="1060" popper-class="material-popover-popper"
1320
+ :persistent="true"
1176
1321
  v-model:visible="materialPopoverVisible" :show-arrow="false">
1177
1322
  <template #reference>
1178
1323
  <div class="toolbar-btn-stacked" :class="{ 'btn-active': materialPopoverVisible || activeTab === 'material' }" title="素材"
@@ -1192,7 +1337,7 @@ function cancelInsertMedia() {
1192
1337
  </template>
1193
1338
 
1194
1339
  <!-- 素材库浮层内容 -->
1195
- <div class="material-popover-panel" style="width: 100%; max-width: 90vw; height: min(650px, calc(82vh - 60px)); display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
1340
+ <div class="material-popover-panel" style="width: 100%; max-width: 95vw; height: min(760px, calc(88vh - 60px)); display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
1196
1341
  <div class="material-popover-header" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border-color, #f1f5f9); background: var(--bg-surface, #fff);">
1197
1342
  <div style="display: flex; align-items: center; gap: 8px;">
1198
1343
  <span style="font-size: 14px; font-weight: 600; color: var(--text-primary);">素材库</span>
@@ -1263,6 +1408,7 @@ function cancelInsertMedia() {
1263
1408
  <span class="btn-label ai-opt-label" style="color: #ec4899; font-weight: 600;">{{ isOptimizingSlide ?
1264
1409
  '美化中...' : '一键美化' }}</span>
1265
1410
  </div>
1411
+ </template>
1266
1412
 
1267
1413
  <!-- 自定义常规工具栏后置插槽 -->
1268
1414
  <slot name="suffix"></slot>
@@ -1288,20 +1434,19 @@ function cancelInsertMedia() {
1288
1434
  justify-content: space-between;
1289
1435
  height: 100%;
1290
1436
  width: 100%;
1291
- padding: 0 16px;
1437
+ padding: 0 6px;
1292
1438
  box-sizing: border-box;
1293
1439
  }
1294
1440
 
1295
1441
  .toolbar-center-group {
1296
1442
  display: flex;
1297
1443
  align-items: center;
1298
- /* gap: 8px; */
1299
1444
  }
1300
1445
 
1301
1446
  .toolbar-ops-group {
1302
1447
  display: flex;
1303
1448
  align-items: center;
1304
- gap: clamp(10px, 1.6vw, 24px);
1449
+ gap: clamp(6px, 1.2vw, 20px);
1305
1450
  flex-shrink: 0;
1306
1451
  }
1307
1452
 
@@ -1309,7 +1454,7 @@ function cancelInsertMedia() {
1309
1454
  width: 1px;
1310
1455
  height: 28px;
1311
1456
  background: rgba(0, 0, 0, 0.08);
1312
- margin: 0 8px;
1457
+ margin: 0 6px;
1313
1458
  }
1314
1459
 
1315
1460
  .btn-trigger-inner {
@@ -1547,15 +1692,91 @@ function cancelInsertMedia() {
1547
1692
  font-size: 11px;
1548
1693
  color: #94a3b8;
1549
1694
  }
1695
+
1696
+ /* 更多功能折叠面板 */
1697
+ .toolbar-more-btn {
1698
+ position: relative;
1699
+ }
1700
+
1701
+ .toolbar-more-panel {
1702
+ display: flex;
1703
+ flex-direction: column;
1704
+ gap: 10px;
1705
+ user-select: none;
1706
+ }
1707
+
1708
+ .toolbar-more-section-title {
1709
+ font-size: 11px;
1710
+ font-weight: 600;
1711
+ color: #94a3b8;
1712
+ padding: 0 4px;
1713
+ text-transform: uppercase;
1714
+ letter-spacing: 0.5px;
1715
+ }
1716
+
1717
+ .toolbar-more-grid {
1718
+ display: grid;
1719
+ grid-template-columns: repeat(4, 1fr);
1720
+ gap: 6px;
1721
+ }
1722
+
1723
+ .toolbar-more-item {
1724
+ display: flex;
1725
+ flex-direction: column;
1726
+ align-items: center;
1727
+ justify-content: center;
1728
+ gap: 6px;
1729
+ padding: 8px 4px;
1730
+ border-radius: 6px;
1731
+ cursor: pointer;
1732
+ transition: all 0.15s ease;
1733
+ color: #334155;
1734
+ font-size: 11px;
1735
+ background: #f8fafc;
1736
+ }
1737
+
1738
+ .toolbar-more-item:hover {
1739
+ background: #eff6ff;
1740
+ color: #2563eb;
1741
+ transform: translateY(-1px);
1742
+ }
1743
+
1744
+ .toolbar-more-item.toolbar-more-ai {
1745
+ background: #fdf2f8;
1746
+ }
1747
+
1748
+ .toolbar-more-item.toolbar-more-ai:hover {
1749
+ background: #fce7f3;
1750
+ color: #db2777;
1751
+ }
1752
+
1753
+ .toolbar-more-icon {
1754
+ width: 22px;
1755
+ height: 22px;
1756
+ fill: currentColor;
1757
+ flex-shrink: 0;
1758
+ }
1759
+
1760
+ .toolbar-more-divider {
1761
+ height: 1px;
1762
+ background: #f1f5f9;
1763
+ margin: 2px 0;
1764
+ }
1550
1765
  </style>
1551
1766
 
1552
1767
  <style>
1768
+ .el-popover.toolbar-more-popover {
1769
+ padding: 12px !important;
1770
+ border-radius: 10px !important;
1771
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.12), 0 8px 10px -6px rgba(0, 0, 0, 0.08) !important;
1772
+ }
1773
+
1553
1774
  .el-popover.material-popover-popper {
1554
1775
  padding: 0 !important;
1555
1776
  border-radius: 8px !important;
1556
1777
  box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;
1557
1778
  overflow: hidden !important;
1558
- max-width: 92vw !important;
1779
+ max-width: 96vw !important;
1559
1780
  }
1560
1781
 
1561
1782
  .material-dock-sidebar-btn {
@@ -34,8 +34,12 @@ const isNearTopEdge = computed(() => {
34
34
  </script>
35
35
 
36
36
  <template>
37
+ <!-- 容器必须保持 pointer-events:none(走下方 scoped CSS)。若整块变 auto 会盖住选中元素的
38
+ 文本区:mousedown/dblclick 的 target 变成容器,CanvasArea 各"放行原生选区"守卫全部失效,
39
+ 表现为编辑态点文字退出编辑、拖动变拖元素、双击不选词;组内子元素还会因 down/up 目标
40
+ 分裂而丢失 dblclick 事件。所有手柄均为子元素、自带 pointer-events:auto,不受影响。 -->
37
41
  <div class="transform-handles-container"
38
- :style="{ '--handle-scale': handleScale, pointerEvents: props.isDragging ? 'none' : 'auto' }">
42
+ :style="{ '--handle-scale': handleScale }">
39
43
  <!-- If it's a line, only render endpoint handles & control points -->
40
44
  <template v-if="element.type === 'line'">
41
45
  <!-- Endpoints -->
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue'
2
+ import { computed, ref, watch, onBeforeUnmount, nextTick } from 'vue'
3
3
  import {
4
4
  getElementPattern,
5
5
  getElementGradientConfig,
@@ -20,6 +20,7 @@ import {
20
20
  import { isEllipseShape } from '../../../utils/line'
21
21
  import { getFontVariantStyle } from '../../../configs/fonts'
22
22
  import { useSlides } from '../../../composables/useSlides'
23
+ import { createTextSelectionOverlay, type TextSelectionOverlayHandle } from '../textSelectionOverlay'
23
24
 
24
25
  const { shapeFormatPainter } = useSlides()
25
26
  const isTextPaintActive = computed(() => !!(shapeFormatPainter.value && shapeFormatPainter.value.mode === 'text-range'))
@@ -29,7 +30,36 @@ function getEffectiveBorderWidth(el: any): number {
29
30
  if (props.isImagePlaceholder && props.isImagePlaceholder(el) && el.style?.enableBorder !== true) return 0
30
31
  if (el.style?.enableBorder === false) return 0
31
32
  const w = getElementBorderWidth(el)
32
- return w > 0 ? w : (el.style?.borderColor || el.borderColor ? 1 : 2)
33
+ if (w > 0) return w
34
+ if (el.style?.borderColor || el.borderColor) return 1
35
+ return 0
36
+ }
37
+
38
+ function getShapeSvgTransform(el: any): string | undefined {
39
+ if (!el) return undefined
40
+ const { width, height } = getElementWidthHeight(el)
41
+ const vbW = el.viewBox ? el.viewBox[0] : (width || 200)
42
+ const vbH = el.viewBox ? el.viewBox[1] : (height || 200)
43
+
44
+ if (el.shapType === 'arc') {
45
+ return `scale(${width / vbW}, ${height / vbH})`
46
+ }
47
+ if (isOpenLineShape(el)) {
48
+ return undefined
49
+ }
50
+
51
+ const hasBorder = hasElementBorder(el)
52
+ const bw = hasBorder ? getEffectiveBorderWidth(el) : 0
53
+
54
+ if (bw > 0) {
55
+ const scaleX = Math.max(0, width - bw) / vbW
56
+ const scaleY = Math.max(0, height - bw) / vbH
57
+ return `translate(${bw / 2}, ${bw / 2}) scale(${scaleX}, ${scaleY})`
58
+ }
59
+
60
+ const scaleX = width / vbW
61
+ const scaleY = height / vbH
62
+ return `scale(${scaleX}, ${scaleY})`
33
63
  }
34
64
 
35
65
  function getEffectiveBorderColor(el: any, fallback = '#10b981'): string {
@@ -75,12 +105,40 @@ const emit = defineEmits<{
75
105
  (e: 'dblclick', ev: MouseEvent): void
76
106
  }>()
77
107
 
108
+ // 编辑态:安装自绘选区高亮(原生 ::selection 几何跟随 CJK 字体度量,条顶空白过大)
109
+ const shapeEditableRef = ref<HTMLElement | null>(null)
110
+ const shapeTextHostRef = ref<HTMLElement | null>(null)
111
+ let selOverlay: TextSelectionOverlayHandle | null = null
112
+
113
+ const isVerticalWriting = computed(() => {
114
+ const wm = props.element.style?.writingMode
115
+ return wm === 'vertical-rl' || wm === 'vertical-lr'
116
+ })
117
+
118
+ watch([isTextSelectable, isVerticalWriting], ([selectable]) => {
119
+ nextTick(() => {
120
+ selOverlay?.destroy()
121
+ selOverlay = null
122
+ if (selectable && !isVerticalWriting.value) {
123
+ const ed = shapeEditableRef.value
124
+ const host = shapeTextHostRef.value
125
+ if (ed && host) selOverlay = createTextSelectionOverlay(ed, host)
126
+ }
127
+ })
128
+ }, { immediate: true })
129
+
130
+ onBeforeUnmount(() => {
131
+ selOverlay?.destroy()
132
+ selOverlay = null
133
+ })
134
+
78
135
  function getElementWidthHeight(el: any): { width: number; height: number } {
79
136
  if (!el || typeof el !== 'object') return { width: 200, height: 200 }
80
137
 
81
138
  const wStr = el.style?.width !== undefined ? String(el.style.width).trim() : ''
82
139
  const hStr = el.style?.height !== undefined ? String(el.style.height).trim() : ''
83
140
 
141
+ // 1. 如果有明确的 px 或非百分比数值(如 '120px', 120)
84
142
  if (wStr && !wStr.endsWith('%') && wStr !== 'auto') {
85
143
  const w = parseFloat(wStr)
86
144
  const h = parseFloat(hStr)
@@ -89,34 +147,48 @@ function getElementWidthHeight(el: any): { width: number; height: number } {
89
147
  }
90
148
  }
91
149
 
92
- // % 样式(组合内子元素):相对直接父盒子的像素尺寸解析,父盒尺寸由
93
- // CanvasGroupElement 经 props 传入。不能依赖下面的 DOM 测量分支 ——
94
- // 本组件渲染时父组件的 wrapper div 还没插入 document,
95
- // getElementById 拿不到,最终退化成 parseFloat('91.8%') 这类垃圾值,
96
- // 导致形状 path 的 scale 初始错误、点击触发重渲染(DOM 已在)后才变对。
150
+ // 2. 如果是 % 样式(组合内子元素):相对直接父盒子的像素尺寸解析
97
151
  if (wStr.endsWith('%')) {
98
152
  const pct = parseFloat(wStr)
99
- const parentW = Number(props.parentWidthPx)
100
- const parentH = Number(props.parentHeightPx)
101
- const hPct = parseFloat(hStr)
102
- const hResolvable = hStr.endsWith('%') && !isNaN(hPct) && isFinite(parentH) && parentH > 0
103
- if (!isNaN(pct) && isFinite(parentW) && parentW > 0 && (hStr === 'auto' || hStr === '' || hResolvable || (typeof el.height === 'number' && el.height > 0))) {
104
- const width = (pct / 100) * parentW
153
+ const pW = Number(props.parentWidthPx)
154
+ const pH = Number(props.parentHeightPx)
155
+
156
+ // 优先:若 props.parentWidthPx 经 props 链传入且有效
157
+ if (!isNaN(pct) && isFinite(pW) && pW > 0) {
158
+ const width = (pct / 100) * pW
105
159
  let height = NaN
106
- if (hResolvable) height = (hPct / 100) * parentH
107
- else if (hStr && hStr !== 'auto' && !hStr.endsWith('%')) {
160
+ const hPct = parseFloat(hStr)
161
+ if (hStr.endsWith('%') && !isNaN(hPct) && isFinite(pH) && pH > 0) {
162
+ height = (hPct / 100) * pH
163
+ } else if (hStr && hStr !== 'auto' && !hStr.endsWith('%')) {
108
164
  const hn = parseFloat(hStr)
109
165
  if (!isNaN(hn) && hn > 0) height = hn
110
166
  }
111
167
  if (isNaN(height) && typeof el.height === 'number' && el.height > 0) height = el.height
112
- if (!isNaN(height)) return { width, height }
168
+ if (isNaN(height) && isFinite(pH) && pH > 0) height = pH
169
+ if (!isNaN(width) && !isNaN(height)) return { width, height: height || 200 }
170
+ }
171
+
172
+ // 关键补救:如果 parentWidthPx 尚未就绪,但 el.width / el.height 本身存在确凿数字(组合时保存的原始像素)
173
+ if (typeof el.width === 'number' && el.width > 0 && typeof el.height === 'number' && el.height > 0) {
174
+ return { width: el.width, height: el.height }
175
+ }
176
+
177
+ // 回退到画布基准 1280x720 进行百分比换算(绝不直接使用 parseFloat('38.46%') 变成 38px)
178
+ if (!isNaN(pct)) {
179
+ const width = (pct / 100) * 1280
180
+ const hPct = parseFloat(hStr)
181
+ const height = (!isNaN(hPct) && hStr.endsWith('%')) ? (hPct / 100) * 720 : (typeof el.height === 'number' && el.height > 0 ? el.height : width)
182
+ return { width, height }
113
183
  }
114
184
  }
115
185
 
186
+ // 3. 元素自身有数值型 width / height 属性
116
187
  if (typeof el.width === 'number' && el.width > 0 && typeof el.height === 'number' && el.height > 0) {
117
188
  return { width: el.width, height: el.height }
118
189
  }
119
190
 
191
+ // 4. DOM 测量兜底
120
192
  if (typeof window !== 'undefined' && el.id) {
121
193
  const dom = document.getElementById(`canvas-el-${el.id}`)
122
194
  if (dom && dom.offsetWidth > 0 && dom.offsetHeight > 0) {
@@ -124,7 +196,13 @@ function getElementWidthHeight(el: any): { width: number; height: number } {
124
196
  }
125
197
  }
126
198
 
127
- return { width: parseFloat(wStr) || 200, height: parseFloat(hStr) || 200 }
199
+ // 5. 最终兜底:如果是 % 严禁直接 parseFloat 当作 px
200
+ let fallbackW = 200
201
+ let fallbackH = 200
202
+ if (wStr && !wStr.endsWith('%') && !isNaN(parseFloat(wStr))) fallbackW = parseFloat(wStr)
203
+ if (hStr && !hStr.endsWith('%') && !isNaN(parseFloat(hStr))) fallbackH = parseFloat(hStr)
204
+
205
+ return { width: fallbackW, height: fallbackH }
128
206
  }
129
207
 
130
208
  function getBevelShadingPaths(el: any) {
@@ -449,7 +527,7 @@ function formatShapeHtml(el: any): string {
449
527
  </template>
450
528
  </defs>
451
529
  <g
452
- :transform="element.shapType === 'arc' ? `scale(${getElementWidthHeight(element).width / (element.viewBox ? element.viewBox[0] : (getElementWidthHeight(element).width || 200))}, ${getElementWidthHeight(element).height / (element.viewBox ? element.viewBox[1] : (getElementWidthHeight(element).height || 200))})` : (isOpenLineShape(element) ? undefined : `translate(${(element.style?.borderWidth ? parseFloat(element.style.borderWidth) : 2) / 2}, ${(element.style?.borderWidth ? parseFloat(element.style.borderWidth) : 2) / 2}) scale(${Math.max(0, getElementWidthHeight(element).width - (element.style?.borderWidth ? parseFloat(element.style.borderWidth) : 2)) / (element.viewBox ? element.viewBox[0] : (getElementWidthHeight(element).width || 200))}, ${Math.max(0, getElementWidthHeight(element).height - (element.style?.borderWidth ? parseFloat(element.style.borderWidth) : 2)) / (element.viewBox ? element.viewBox[1] : (getElementWidthHeight(element).height || 200))})`)"
530
+ :transform="getShapeSvgTransform(element)"
453
531
  >
454
532
  <path
455
533
  vector-effect="non-scaling-stroke"
@@ -572,11 +650,12 @@ function formatShapeHtml(el: any): string {
572
650
  transform: ((element.style as any)?.flipH || element.flipH || element.isFlipH || (element.style as any)?.flipV || element.flipV || element.isFlipV) ? `scale(${((element.style as any)?.flipH || element.flipH || element.isFlipH) ? -1 : 1}, ${((element.style as any)?.flipV || element.flipV || element.isFlipV) ? -1 : 1})` : undefined
573
651
  }"
574
652
  >
575
- <div class="text-reflection-wrapper" style="position: relative; pointer-events: none;" :style="{
653
+ <div ref="shapeTextHostRef" class="text-reflection-wrapper" style="position: relative; pointer-events: none;" :style="{
576
654
  width: (element.style?.writingMode === 'vertical-rl' || element.style?.writingMode === 'vertical-lr') ? 'auto' : '100%',
577
655
  height: (element.style?.writingMode === 'vertical-rl' || element.style?.writingMode === 'vertical-lr') ? '100%' : 'auto'
578
656
  }">
579
657
  <div
658
+ ref="shapeEditableRef"
580
659
  :id="`editable-${element.id}`"
581
660
  :contenteditable="isTextSelectable ? 'true' : 'false'"
582
661
  @mousedown="isTextSelectable ? $event.stopPropagation() : null"
@@ -1,7 +1,8 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch, onMounted } from 'vue'
2
+ import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
3
3
  import { getFontVariantStyle } from '../../../configs/fonts'
4
4
  import { useSlides } from '../../../composables/useSlides'
5
+ import { createTextSelectionOverlay, type TextSelectionOverlayHandle } from '../textSelectionOverlay'
5
6
 
6
7
  const { shapeFormatPainter } = useSlides()
7
8
  const isTextPaintActive = computed(() => !!(shapeFormatPainter.value && shapeFormatPainter.value.mode === 'text-range'))
@@ -27,8 +28,9 @@ const targetHtml = computed(() => {
27
28
 
28
29
  function syncContentToDom(force = false) {
29
30
  if (!editableRef.value) return
30
- // 如果当前正在编辑并且当前元素是焦点所在,绝不强制用 HTML 冲刷 DOM,防止光标跳到开头
31
- if (!force && document.activeElement === editableRef.value) {
31
+ // 编辑中(焦点在元素自身或其内部——表格编辑时焦点在动态设 contenteditable 的 td 上)
32
+ // 绝不强制用 HTML 冲刷 DOM,防止光标/选中丢失;结构操作经 commitTableContent 强制同步
33
+ if (!force && document.activeElement && editableRef.value.contains(document.activeElement)) {
32
34
  return
33
35
  }
34
36
  const newHtml = targetHtml.value || ''
@@ -51,6 +53,32 @@ onMounted(() => {
51
53
  syncContentToDom(true)
52
54
  })
53
55
 
56
+ // 编辑态:安装自绘选区高亮(原生 ::selection 几何跟随 CJK 字体度量,条顶空白过大)
57
+ const wrapperRef = ref<HTMLElement | null>(null)
58
+ let selOverlay: TextSelectionOverlayHandle | null = null
59
+
60
+ const isVerticalWriting = computed(() => {
61
+ const wm = props.element.style?.writingMode
62
+ return wm === 'vertical-rl' || wm === 'vertical-lr'
63
+ })
64
+
65
+ watch([isTextSelectable, isVerticalWriting], ([selectable]) => {
66
+ nextTick(() => {
67
+ selOverlay?.destroy()
68
+ selOverlay = null
69
+ if (selectable && !isVerticalWriting.value && !props.element.latex) {
70
+ const ed = editableRef.value
71
+ const host = wrapperRef.value
72
+ if (ed && host) selOverlay = createTextSelectionOverlay(ed, host)
73
+ }
74
+ })
75
+ }, { immediate: true })
76
+
77
+ onBeforeUnmount(() => {
78
+ selOverlay?.destroy()
79
+ selOverlay = null
80
+ })
81
+
54
82
  const emit = defineEmits<{
55
83
  (e: 'focusin'): void
56
84
  (e: 'focusout', ev: FocusEvent): void
@@ -129,6 +157,7 @@ function handleMaskMouseDown(e: MouseEvent) {
129
157
 
130
158
  <template>
131
159
  <div
160
+ ref="wrapperRef"
132
161
  class="text-reflection-wrapper"
133
162
  :style="{
134
163
  position: 'relative',
@@ -202,12 +231,13 @@ function handleMaskMouseDown(e: MouseEvent) {
202
231
  ['-webkit-text-stroke']: getTextStrokeStyle ? getTextStrokeStyle(element) : 'none',
203
232
  ['paint-order']: element.style?.enableTextStroke ? 'stroke fill' : 'normal',
204
233
  ['text-shadow']: getElementTextShadow ? getElementTextShadow(element) : 'none',
205
- backgroundColor: 'transparent',
206
- borderWidth: '0px',
207
- borderStyle: 'solid',
208
- borderColor: 'transparent',
209
- borderRadius: '0px',
210
- lineHeight: element.style?.lineHeight || 'normal',
234
+ // 与顶层 text 元素对齐:填充/边框来自 style(否则组内子元素设置填充、边框颜色不生效)
235
+ backgroundColor: element.style?.fill || 'transparent',
236
+ borderWidth: element.style?.borderWidth || '0px',
237
+ borderStyle: element.style?.borderStyle || 'solid',
238
+ borderColor: element.style?.borderColor || 'transparent',
239
+ borderRadius: element.style?.borderRadius || '0px',
240
+ lineHeight: element.style?.lineHeight || element.lineHeight || element.text?.lineHeight || 'normal',
211
241
  letterSpacing: element.style?.letterSpacing || 'normal',
212
242
  textAlign: (element.style?.textAlign === 'distribute' ? 'justify' : (element.style?.textAlign || 'left')) as any,
213
243
  textAlignLast: (element.style?.textAlign === 'distribute' ? 'justify' : 'auto') as any,