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
@@ -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
+ }