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
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 画布内编辑态自绘文字选区高亮。
|
|
3
|
+
*
|
|
4
|
+
* 背景:Chrome 的 ::selection 高亮几何跟随行盒/字体度量,无法用 CSS 控制。
|
|
5
|
+
* 导入的字体栈常是 'Lato Light' 等拉丁字体名,本机未安装时回退到
|
|
6
|
+
* SourceHanSansCN 等 CJK 字体(ascent≈1.16em / descent≈0.29em,远大于拉丁
|
|
7
|
+
* 字形 ink ≈0.93em),于是原生高亮条在文字上方留出大片空白、行间出现宽空白
|
|
8
|
+
* 带,观感是"选区浮在文字上方 / 字贴条底"。
|
|
9
|
+
*
|
|
10
|
+
* 方案:编辑态隐藏原生高亮(.text-selection-overlay-active ::selection 透明),
|
|
11
|
+
* 用 range 的每行矩形定 x 范围,再遍历行内文本节点计算各节点实际字形 ink:
|
|
12
|
+
*
|
|
13
|
+
* 基线测量(关键):DOM 行内片段的内容盒用的是字体 winAsc/winDesc 度量,
|
|
14
|
+
* 而 canvas measureText 的 fontBoundingBox* 是 typo/hhea 度量——两套坐标系
|
|
15
|
+
* 差 0.1~0.3em,直接 `rect.bottom − fontBoundingBoxDescent` 反推基线会把
|
|
16
|
+
* 基线压低、条整体下坠盖不住字顶(v2 的教训)。因此用「镜像 span + 零尺寸
|
|
17
|
+
* inline-block」在页内实测渲染字体的真实基线:line-height:normal 的镜像
|
|
18
|
+
* span 内容盒高 = winAsc+winDesc,零尺寸 inline-block(空内容,底边贴基线)
|
|
19
|
+
* 的 rect.top 即基线位置。
|
|
20
|
+
*
|
|
21
|
+
* ink 高度:canvas measureText('国Mg' / 'Mg') 的 actualBoundingBoxAscent/
|
|
22
|
+
* Descent(真实字形墨迹,按行内是否含 CJK 选探针)。
|
|
23
|
+
*
|
|
24
|
+
* 节点基线 = 节点矩形底 − (矩形高 − winAsc − winDesc)/2 − winDesc
|
|
25
|
+
* (行内盒按 half-leading 模型在自身 line-height 盒内居中)。
|
|
26
|
+
* 行内各节点 ink 取并集 ±1.5px 作为高亮条,画在宿主元素内的 pe:none
|
|
27
|
+
* 覆盖层上。表格文本同样适用:选中发生在 td/th 内时度量作用域收窄到该
|
|
28
|
+
* 单元格(同一行其他单元格字体不参与 ink 并集)。
|
|
29
|
+
* 不改任何文本布局,导入标定(textInset/行距等)不受影响。
|
|
30
|
+
*
|
|
31
|
+
* 用法:createTextSelectionOverlay(editableEl, hostEl),hostEl 需与 editable
|
|
32
|
+
* 处于同一变换坐标系(通常是 editable 的 position:relative 父级)。
|
|
33
|
+
* 编辑态开启时安装、关闭/卸载时 destroy()。
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export interface TextSelectionOverlayHandle {
|
|
37
|
+
destroy(): void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 混排探针(含 CJK)与纯拉丁探针:按行内是否含 CJK 选对应 ink 度量
|
|
41
|
+
const PROBE_MIXED = '国Mg'
|
|
42
|
+
const PROBE_LATIN = 'Mg'
|
|
43
|
+
const CJK_RE = /[ᄀ-ᇿ -ヿ㐀-䶿一-鿿가-豈--]/
|
|
44
|
+
const BAR_PAD = 1.5
|
|
45
|
+
|
|
46
|
+
/** 字形 ink 度量(canvas actualBoundingBox,px) */
|
|
47
|
+
interface InkMetrics {
|
|
48
|
+
taMixed: number
|
|
49
|
+
tdMixed: number
|
|
50
|
+
taLatin: number
|
|
51
|
+
tdLatin: number
|
|
52
|
+
fontSize: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 渲染字体的真实内容盒度量(winAsc/winDesc,px)——镜像 span 实测 */
|
|
56
|
+
interface RenderedFontBox {
|
|
57
|
+
winAsc: number
|
|
58
|
+
winDesc: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface LineBox {
|
|
62
|
+
top: number
|
|
63
|
+
bottom: number
|
|
64
|
+
left: number
|
|
65
|
+
right: number
|
|
66
|
+
/** 该行内所有文本节点实际字形 ink 的并集(无节点信息时为 undefined) */
|
|
67
|
+
inkTop?: number
|
|
68
|
+
inkBottom?: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createTextSelectionOverlay(editableEl: HTMLElement, hostEl: HTMLElement): TextSelectionOverlayHandle {
|
|
72
|
+
const overlay = document.createElement('div')
|
|
73
|
+
overlay.className = 'text-selection-overlay'
|
|
74
|
+
|
|
75
|
+
let destroyed = false
|
|
76
|
+
let failed = false
|
|
77
|
+
let rafId = 0
|
|
78
|
+
|
|
79
|
+
const mctx = document.createElement('canvas').getContext('2d')
|
|
80
|
+
const inkCache = new Map<string, InkMetrics>()
|
|
81
|
+
const fontBoxCache = new Map<string, RenderedFontBox>()
|
|
82
|
+
|
|
83
|
+
function inkMetricsFor(el: Element): InkMetrics | null {
|
|
84
|
+
if (!mctx) return null
|
|
85
|
+
const cs = getComputedStyle(el)
|
|
86
|
+
const key = `${cs.fontStyle}|${cs.fontWeight}|${cs.fontSize}|${cs.fontFamily}`
|
|
87
|
+
let m = inkCache.get(key)
|
|
88
|
+
if (!m) {
|
|
89
|
+
const font = cs.font || `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`
|
|
90
|
+
mctx.font = font
|
|
91
|
+
const mixed = mctx.measureText(PROBE_MIXED)
|
|
92
|
+
const latin = mctx.measureText(PROBE_LATIN)
|
|
93
|
+
m = {
|
|
94
|
+
taMixed: mixed.actualBoundingBoxAscent,
|
|
95
|
+
tdMixed: mixed.actualBoundingBoxDescent,
|
|
96
|
+
taLatin: latin.actualBoundingBoxAscent,
|
|
97
|
+
tdLatin: latin.actualBoundingBoxDescent,
|
|
98
|
+
fontSize: parseFloat(cs.fontSize) || 0
|
|
99
|
+
}
|
|
100
|
+
if (!isFinite(m.taLatin) || !isFinite(m.tdLatin) || !isFinite(m.fontSize)) return null
|
|
101
|
+
inkCache.set(key, m)
|
|
102
|
+
}
|
|
103
|
+
return m
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 镜像 span + 零尺寸 inline-block 实测渲染字体的 winAsc/winDesc:
|
|
107
|
+
// line-height:normal 的镜像内容盒高 = winAsc+winDesc;
|
|
108
|
+
// 空 inline-block 底边贴基线,其 rect.top 就是基线。
|
|
109
|
+
function renderedFontBoxFor(el: Element): RenderedFontBox | null {
|
|
110
|
+
const cs = getComputedStyle(el)
|
|
111
|
+
const key = cs.font || `${cs.fontStyle}|${cs.fontWeight}|${cs.fontSize}|${cs.fontFamily}`
|
|
112
|
+
let m = fontBoxCache.get(key)
|
|
113
|
+
if (m) return m
|
|
114
|
+
const font = cs.font || `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`
|
|
115
|
+
const mirror = document.createElement('span')
|
|
116
|
+
mirror.style.cssText = `position:fixed;left:-99999px;top:0;visibility:hidden;white-space:pre;line-height:normal;font:${font};`
|
|
117
|
+
const ib = document.createElement('span')
|
|
118
|
+
ib.style.cssText = 'display:inline-block;width:0;height:0;'
|
|
119
|
+
mirror.appendChild(document.createTextNode('x'))
|
|
120
|
+
mirror.appendChild(ib)
|
|
121
|
+
document.body.appendChild(mirror)
|
|
122
|
+
try {
|
|
123
|
+
const hr = mirror.getBoundingClientRect()
|
|
124
|
+
const ibr = ib.getBoundingClientRect()
|
|
125
|
+
if (hr.height < 1 || !isFinite(ibr.top) || !isFinite(hr.bottom)) return null
|
|
126
|
+
const winAsc = ibr.top - hr.top
|
|
127
|
+
const winDesc = hr.bottom - ibr.top
|
|
128
|
+
if (!(winAsc > 0) || !(winDesc > 0) || !isFinite(winAsc) || !isFinite(winDesc)) return null
|
|
129
|
+
m = { winAsc, winDesc }
|
|
130
|
+
fontBoxCache.set(key, m)
|
|
131
|
+
return m
|
|
132
|
+
} catch {
|
|
133
|
+
return null
|
|
134
|
+
} finally {
|
|
135
|
+
mirror.remove()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function collectLineBoxes(range: Range): LineBox[] {
|
|
140
|
+
const lines: LineBox[] = []
|
|
141
|
+
const rects = Array.from(range.getClientRects()).filter(r => r.width >= 1 && r.height >= 1)
|
|
142
|
+
for (const r of rects) {
|
|
143
|
+
const L = lines.find(l => Math.abs(l.top - r.top) < 2)
|
|
144
|
+
if (L) {
|
|
145
|
+
L.top = Math.min(L.top, r.top)
|
|
146
|
+
L.bottom = Math.max(L.bottom, r.bottom)
|
|
147
|
+
L.left = Math.min(L.left, r.left)
|
|
148
|
+
L.right = Math.max(L.right, r.right)
|
|
149
|
+
} else {
|
|
150
|
+
lines.push({ top: r.top, bottom: r.bottom, left: r.left, right: r.right })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
lines.sort((a, b) => a.top - b.top)
|
|
154
|
+
return lines
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 选区所在表格单元格(表格文本的选中发生在动态设为 contenteditable 的
|
|
158
|
+
// td/th 内):把度量作用域收窄到该 cell,避免同一行其他单元格的字体
|
|
159
|
+
// 污染本行 ink 并集;非表格返回 null。
|
|
160
|
+
function scopeFor(range: Range): ParentNode {
|
|
161
|
+
let n: Node | null = range.startContainer
|
|
162
|
+
if (n.nodeType === Node.TEXT_NODE) n = n.parentElement
|
|
163
|
+
while (n && n !== editableEl) {
|
|
164
|
+
if (n.nodeType === Node.ELEMENT_NODE) {
|
|
165
|
+
const tag = (n as Element).tagName
|
|
166
|
+
if (tag === 'TD' || tag === 'TH') return n as HTMLElement
|
|
167
|
+
}
|
|
168
|
+
n = n.parentNode
|
|
169
|
+
}
|
|
170
|
+
return editableEl
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 遍历编辑区内直接持有文本的节点,用「节点自身矩形 + 该字体实测基线 +
|
|
174
|
+
// canvas 字形 ink」算出每个节点在该行的实际字形 ink,行内取并集。
|
|
175
|
+
// 基线绝不能从行盒底/容器字体反推(strut、行距、win/typo 度量差异都会
|
|
176
|
+
// 把条压低),只能从节点自身字体实测。
|
|
177
|
+
function accumulateLineMetrics(lines: LineBox[], scope: ParentNode) {
|
|
178
|
+
if (!lines.length) return
|
|
179
|
+
const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT)
|
|
180
|
+
let node: Node | null
|
|
181
|
+
while ((node = walker.nextNode())) {
|
|
182
|
+
const text = node.nodeValue || ''
|
|
183
|
+
if (!text.trim()) continue
|
|
184
|
+
const parent = node.parentElement
|
|
185
|
+
if (!parent || parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE') continue
|
|
186
|
+
const ink = inkMetricsFor(parent)
|
|
187
|
+
const box = renderedFontBoxFor(parent)
|
|
188
|
+
if (!ink || !box) continue
|
|
189
|
+
const hasCJK = CJK_RE.test(text)
|
|
190
|
+
const ta = hasCJK ? ink.taMixed : ink.taLatin
|
|
191
|
+
const td = hasCJK ? ink.tdMixed : ink.tdLatin
|
|
192
|
+
const nr = document.createRange()
|
|
193
|
+
nr.selectNodeContents(node)
|
|
194
|
+
for (const r of nr.getClientRects()) {
|
|
195
|
+
if (r.height < 1 || r.width < 1) continue
|
|
196
|
+
const lead = (r.height - box.winAsc - box.winDesc) / 2
|
|
197
|
+
// 行距异常(负 leading 超过字号一半)视为不可信,跳过该节点
|
|
198
|
+
if (!isFinite(lead) || Math.abs(lead) > ink.fontSize * 1.5) continue
|
|
199
|
+
const baseline = r.bottom - lead - box.winDesc
|
|
200
|
+
const inkTop = baseline - ta
|
|
201
|
+
const inkBottom = baseline + td
|
|
202
|
+
const mid = (r.top + r.bottom) / 2
|
|
203
|
+
for (const L of lines) {
|
|
204
|
+
if (mid < L.top - 1 || mid > L.bottom + 1) continue
|
|
205
|
+
L.inkTop = Math.min(L.inkTop ?? Infinity, inkTop)
|
|
206
|
+
L.inkBottom = Math.max(L.inkBottom ?? -Infinity, inkBottom)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function draw() {
|
|
213
|
+
overlay.textContent = ''
|
|
214
|
+
hostEl.classList.remove('text-selection-overlay-active')
|
|
215
|
+
if (destroyed || failed) return
|
|
216
|
+
|
|
217
|
+
const sel = window.getSelection()
|
|
218
|
+
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return
|
|
219
|
+
const range = sel.getRangeAt(0)
|
|
220
|
+
if (!editableEl.contains(range.commonAncestorContainer)) return
|
|
221
|
+
|
|
222
|
+
const hostRect = hostEl.getBoundingClientRect()
|
|
223
|
+
if (!hostRect.width || !hostEl.offsetWidth) return
|
|
224
|
+
const scale = hostRect.width / hostEl.offsetWidth
|
|
225
|
+
|
|
226
|
+
const lines = collectLineBoxes(range)
|
|
227
|
+
if (!lines.length) return
|
|
228
|
+
accumulateLineMetrics(lines, scopeFor(range))
|
|
229
|
+
|
|
230
|
+
const frag = document.createDocumentFragment()
|
|
231
|
+
let drew = 0
|
|
232
|
+
for (const L of lines) {
|
|
233
|
+
// 条 = 行内实际字形 ink 并集 ± 边距;拿不到节点度量时退回整行盒
|
|
234
|
+
let top: number
|
|
235
|
+
let bottom: number
|
|
236
|
+
if (L.inkTop !== undefined && L.inkBottom !== undefined && L.inkBottom - L.inkTop >= 2) {
|
|
237
|
+
top = L.inkTop - BAR_PAD
|
|
238
|
+
bottom = L.inkBottom + BAR_PAD
|
|
239
|
+
// 保险:异常度量不许把条拉出行盒太远
|
|
240
|
+
top = Math.max(top, L.top - 3)
|
|
241
|
+
bottom = Math.min(bottom, L.bottom + 3)
|
|
242
|
+
} else {
|
|
243
|
+
top = L.top
|
|
244
|
+
bottom = L.bottom
|
|
245
|
+
}
|
|
246
|
+
const bar = document.createElement('div')
|
|
247
|
+
bar.className = 'text-selection-bar'
|
|
248
|
+
bar.style.left = `${(L.left - hostRect.left) / scale}px`
|
|
249
|
+
bar.style.top = `${(top - hostRect.top) / scale}px`
|
|
250
|
+
bar.style.width = `${(L.right - L.left) / scale}px`
|
|
251
|
+
bar.style.height = `${(bottom - top) / scale}px`
|
|
252
|
+
frag.appendChild(bar)
|
|
253
|
+
drew++
|
|
254
|
+
}
|
|
255
|
+
if (!drew) return
|
|
256
|
+
overlay.appendChild(frag)
|
|
257
|
+
// 诊断用:行盒 vs 字形 ink 并集(排障时可直接从 DOM 读)
|
|
258
|
+
try {
|
|
259
|
+
overlay.dataset.diag = JSON.stringify(lines.map(l => [
|
|
260
|
+
+l.top.toFixed(1), +l.bottom.toFixed(1),
|
|
261
|
+
l.inkTop !== undefined ? +l.inkTop.toFixed(1) : null,
|
|
262
|
+
l.inkBottom !== undefined ? +l.inkBottom.toFixed(1) : null
|
|
263
|
+
]))
|
|
264
|
+
} catch { /* ignore */ }
|
|
265
|
+
if (!overlay.isConnected) {
|
|
266
|
+
if (getComputedStyle(hostEl).position === 'static') hostEl.style.position = 'relative'
|
|
267
|
+
hostEl.appendChild(overlay)
|
|
268
|
+
}
|
|
269
|
+
// 有自绘条才隐藏原生高亮(失败时保留原生兜底)
|
|
270
|
+
hostEl.classList.add('text-selection-overlay-active')
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function schedule() {
|
|
274
|
+
if (destroyed || failed) return
|
|
275
|
+
cancelAnimationFrame(rafId)
|
|
276
|
+
rafId = requestAnimationFrame(() => {
|
|
277
|
+
try {
|
|
278
|
+
draw()
|
|
279
|
+
} catch {
|
|
280
|
+
failed = true
|
|
281
|
+
overlay.textContent = ''
|
|
282
|
+
hostEl.classList.remove('text-selection-overlay-active')
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const onSelectionChange = () => schedule()
|
|
288
|
+
document.addEventListener('selectionchange', onSelectionChange)
|
|
289
|
+
// 画布平移/缩放(祖先 transform 变化不触发 selectionchange/resize)
|
|
290
|
+
window.addEventListener('scroll', onSelectionChange, true)
|
|
291
|
+
window.addEventListener('resize', onSelectionChange)
|
|
292
|
+
|
|
293
|
+
function destroy() {
|
|
294
|
+
if (destroyed) return
|
|
295
|
+
destroyed = true
|
|
296
|
+
cancelAnimationFrame(rafId)
|
|
297
|
+
document.removeEventListener('selectionchange', onSelectionChange)
|
|
298
|
+
window.removeEventListener('scroll', onSelectionChange, true)
|
|
299
|
+
window.removeEventListener('resize', onSelectionChange)
|
|
300
|
+
overlay.remove()
|
|
301
|
+
hostEl.classList.remove('text-selection-overlay-active')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { destroy }
|
|
305
|
+
}
|