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.
- package/README.md +60 -2
- package/dist/lib/jvs-ppt-ui.es.js +22404 -21454
- package/dist/lib/jvs-ppt-ui.umd.js +102 -101
- package/dist/lib/style.css +1 -1
- package/package.json +1 -1
- package/src/App.vue +53 -53
- package/src/components/AIPptDialog.vue +2 -0
- package/src/components/CanvasArea.vue +164 -72
- package/src/components/Header.vue +374 -144
- package/src/components/PptEditor.vue +33 -15
- package/src/components/PresentationMode.vue +24 -4
- package/src/components/SidebarRight.vue +8 -1
- package/src/components/SvgChart.vue +5 -2
- package/src/components/TemplateGallery.vue +1321 -866
- package/src/components/Toolbar.vue +244 -22
- package/src/components/canvas/CanvasTransformHandles.vue +5 -1
- package/src/components/canvas/elements/CanvasShapeElement.vue +98 -19
- package/src/components/canvas/elements/CanvasTextElement.vue +39 -9
- package/src/components/canvas/textSelectionOverlay.ts +305 -0
- package/src/composables/canvas/useCanvasAlignment.ts +286 -238
- package/src/composables/canvas/useCanvasContextMenu.ts +53 -22
- package/src/composables/canvas/useCanvasFormatting.ts +35 -12
- package/src/composables/canvas/useCanvasTable.ts +52 -24
- package/src/composables/canvas/useCanvasText.ts +12 -0
- package/src/composables/canvas/useCanvasTransform.ts +15 -2
- package/src/composables/sidebar/useSidebarElementTransform.ts +5 -5
- package/src/composables/sidebar/useSidebarShapeFormat.ts +2 -2
- package/src/composables/useSlides.ts +495 -132
- package/src/style.css +88 -0
- package/src/types/index.ts +8 -0
- package/src/utils/line.ts +274 -312
- package/src/utils/pptxShapes.ts +1 -1
- package/src/utils/pptxShapesLazy.ts +39 -0
- 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
|
-
<!--
|
|
992
|
-
<
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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="
|
|
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:
|
|
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,27 +1434,27 @@ function cancelInsertMedia() {
|
|
|
1288
1434
|
justify-content: space-between;
|
|
1289
1435
|
height: 100%;
|
|
1290
1436
|
width: 100%;
|
|
1291
|
-
padding: 0
|
|
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:
|
|
1449
|
+
gap: clamp(6px, 1.2vw, 20px);
|
|
1450
|
+
flex-shrink: 0;
|
|
1305
1451
|
}
|
|
1306
1452
|
|
|
1307
1453
|
.toolbar-v-divider {
|
|
1308
1454
|
width: 1px;
|
|
1309
1455
|
height: 28px;
|
|
1310
1456
|
background: rgba(0, 0, 0, 0.08);
|
|
1311
|
-
margin: 0
|
|
1457
|
+
margin: 0 6px;
|
|
1312
1458
|
}
|
|
1313
1459
|
|
|
1314
1460
|
.btn-trigger-inner {
|
|
@@ -1546,15 +1692,91 @@ function cancelInsertMedia() {
|
|
|
1546
1692
|
font-size: 11px;
|
|
1547
1693
|
color: #94a3b8;
|
|
1548
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
|
+
}
|
|
1549
1765
|
</style>
|
|
1550
1766
|
|
|
1551
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
|
+
|
|
1552
1774
|
.el-popover.material-popover-popper {
|
|
1553
1775
|
padding: 0 !important;
|
|
1554
1776
|
border-radius: 8px !important;
|
|
1555
1777
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;
|
|
1556
1778
|
overflow: hidden !important;
|
|
1557
|
-
max-width:
|
|
1779
|
+
max-width: 96vw !important;
|
|
1558
1780
|
}
|
|
1559
1781
|
|
|
1560
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
|
|
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
|
-
|
|
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
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (!isNaN(pct) && isFinite(
|
|
104
|
-
const width = (pct / 100) *
|
|
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
|
-
|
|
107
|
-
|
|
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 (
|
|
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
|
-
|
|
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="
|
|
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
|
-
//
|
|
31
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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,
|