| 1 | import { describe, expect, it } from 'vitest' |
| 2 | |
| 3 | import { |
| 4 | ensureElementAnchorInHtml, |
| 5 | patchDraggedElementStyle, |
| 6 | patchGenericElementProperties |
| 7 | } from '../../../src/main/element-editor/shared' |
| 8 | |
| 9 | describe('ensureElementAnchorInHtml', () => { |
| 10 | it('keeps an existing block id only when it is unique', () => { |
| 11 | const html = ` |
| 12 | <html><body data-page-id="page"> |
| 13 | <main data-block-id="content" data-role="content"> |
| 14 | <p data-block-id="text">first</p> |
| 15 | <p data-block-id="text">second</p> |
| 16 | </main> |
| 17 | </body></html> |
| 18 | ` |
| 19 | |
| 20 | const result = ensureElementAnchorInHtml(html, { |
| 21 | pageId: 'page', |
| 22 | selector: 'body[data-page-id="page"] main > p:nth-child(2)', |
| 23 | elementTag: 'p' |
| 24 | }) |
| 25 | |
| 26 | expect(result.changed).toBe(true) |
| 27 | expect(result.blockId).not.toBe('text') |
| 28 | expect(result.selector).toBe(`body[data-page-id="page"] [data-block-id="${result.blockId}"]`) |
| 29 | expect(result.html).toContain(`<p data-block-id="${result.blockId}">second</p>`) |
| 30 | }) |
| 31 | |
| 32 | it('anchors rendered KaTeX itself instead of the surrounding formula block', () => { |
| 33 | const html = ` |
| 34 | <html><body data-page-id="page"> |
| 35 | <main data-block-id="content" data-role="content"> |
| 36 | <div data-block-id="formula-card"> |
| 37 | <span class="katex"> |
| 38 | <span class="katex-mathml"> |
| 39 | <math><semantics><mrow></mrow><annotation encoding="application/x-tex">x^2</annotation></semantics></math> |
| 40 | </span> |
| 41 | <span class="katex-html">x2</span> |
| 42 | </span> |
| 43 | </div> |
| 44 | </main> |
| 45 | </body></html> |
| 46 | ` |
| 47 | |
| 48 | const result = ensureElementAnchorInHtml(html, { |
| 49 | pageId: 'page', |
| 50 | selector: 'body[data-page-id="page"] .katex', |
| 51 | elementTag: 'span' |
| 52 | }) |
| 53 | |
| 54 | expect(result.selector).toBe(`body[data-page-id="page"] [data-block-id="${result.blockId}"]`) |
| 55 | expect(result.html).toContain(`<span class="katex" data-block-id="${result.blockId}">`) |
| 56 | expect(result.html).toContain('<div data-block-id="formula-card">') |
| 57 | }) |
| 58 | |
| 59 | it('materializes a runtime KaTeX formula when the source only has LaTeX delimiters', () => { |
| 60 | const html = ` |
| 61 | <html><body data-page-id="page"> |
| 62 | <main data-block-id="content" data-role="content"> |
| 63 | <div data-block-id="formula-card">\\( a^2 + b^2 = c^2 \\)</div> |
| 64 | </main> |
| 65 | </body></html> |
| 66 | ` |
| 67 | |
| 68 | const result = ensureElementAnchorInHtml(html, { |
| 69 | pageId: 'page', |
| 70 | selector: 'body[data-page-id="page"] .ppt-page-root .katex', |
| 71 | elementTag: 'span', |
| 72 | formula: { |
| 73 | latex: 'a^2 + b^2 = c^2', |
| 74 | html: '<span class="katex"><span class="katex-mathml"><math><semantics><mrow></mrow><annotation encoding="application/x-tex">a^2 + b^2 = c^2</annotation></semantics></math></span><span class="katex-html">a2+b2=c2</span></span>', |
| 75 | displayMode: false |
| 76 | } |
| 77 | }) |
| 78 | |
| 79 | expect(result.selector).toBe(`body[data-page-id="page"] [data-block-id="${result.blockId}"]`) |
| 80 | expect(result.html).toContain(`<span class="katex" data-ppt-formula-latex="a^2 + b^2 = c^2"`) |
| 81 | expect(result.html).toContain(`data-block-id="${result.blockId}"`) |
| 82 | expect(result.html).not.toContain('\\( a^2 + b^2 = c^2 \\)') |
| 83 | }) |
| 84 | |
| 85 | }) |
| 86 | |
| 87 | describe('patchDraggedElementStyle chart sizing', () => { |
| 88 | it('does not persist canvas child width or height when resizing a chart frame', () => { |
| 89 | const html = ` |
| 90 | <html><body data-page-id="page"> |
| 91 | <div data-block-id="chart" class="ppt-chart-frame"> |
| 92 | <canvas id="chart-canvas" class="h-full w-full"></canvas> |
| 93 | </div> |
| 94 | </body></html> |
| 95 | ` |
| 96 | |
| 97 | const result = patchDraggedElementStyle( |
| 98 | html, |
| 99 | 'body[data-page-id="page"] [data-block-id="chart"]', |
| 100 | 0, |
| 101 | 0, |
| 102 | 500, |
| 103 | 320, |
| 104 | [{ path: [0], width: 500, height: 300 }], |
| 105 | false |
| 106 | ) |
| 107 | |
| 108 | expect(result).toContain('width: 500px; height: 320px') |
| 109 | expect(result).toContain('<canvas id="chart-canvas" class="h-full w-full"></canvas>') |
| 110 | expect(result).not.toContain('height: 300px') |
| 111 | }) |
| 112 | }) |
| 113 | |
| 114 | describe('patchDraggedElementStyle flow layout resizing', () => { |
| 115 | const selector = 'body[data-page-id="page"] [data-block-id="flow"]' |
| 116 | |
| 117 | it('persists translate and actual dimensions after layout isolation', () => { |
| 118 | const html = ` |
| 119 | <html><body data-page-id="page"> |
| 120 | <div data-block-id="flow" style="width: 240px; height: 100px"> |
| 121 | <span data-block-id="child" style="width: 80px">Content</span> |
| 122 | </div> |
| 123 | </body></html> |
| 124 | ` |
| 125 | |
| 126 | const result = patchDraggedElementStyle( |
| 127 | html, |
| 128 | selector, |
| 129 | 36, |
| 130 | -12, |
| 131 | 360, |
| 132 | 75, |
| 133 | [{ path: [0], width: 120, height: null }], |
| 134 | false |
| 135 | ) |
| 136 | |
| 137 | expect(result).toContain('--ppt-drag-x: 36px') |
| 138 | expect(result).toContain('--ppt-drag-y: -12px') |
| 139 | expect(result).toContain('width: 360px') |
| 140 | expect(result).toContain('height: 75px') |
| 141 | expect(result).toContain('width: 120px') |
| 142 | }) |
| 143 | }) |
| 144 | |
| 145 | describe('patchGenericElementProperties rich text', () => { |
| 146 | it('updates inline rich text without flattening spans', () => { |
| 147 | const html = ` |
| 148 | <html><body data-page-id="page"> |
| 149 | <p data-block-id="text"><span style="color:#FB4526" data-block-id="text-1">南欧</span>旧文字</p> |
| 150 | </body></html> |
| 151 | ` |
| 152 | |
| 153 | const result = patchGenericElementProperties( |
| 154 | html, |
| 155 | 'body[data-page-id="page"] [data-block-id="text"]', |
| 156 | { |
| 157 | html: '<span style="color:#FB4526" data-block-id="text-1">南欧</span>新文字' |
| 158 | } |
| 159 | ) |
| 160 | |
| 161 | expect(result).toContain( |
| 162 | '<span style="color:#FB4526" data-block-id="text-1">南欧</span>新文字' |
| 163 | ) |
| 164 | }) |
| 165 | |
| 166 | it('strips editor-only zoom from rich text before writing html', () => { |
| 167 | const html = ` |
| 168 | <html><body data-page-id="page"> |
| 169 | <p data-block-id="text">旧文字</p> |
| 170 | </body></html> |
| 171 | ` |
| 172 | |
| 173 | const result = patchGenericElementProperties( |
| 174 | html, |
| 175 | 'body[data-page-id="page"] [data-block-id="text"]', |
| 176 | { |
| 177 | html: '<span style="zoom: 0.5; color: #FB4526; font-size: 60px">新文字</span>' |
| 178 | } |
| 179 | ) |
| 180 | |
| 181 | expect(result).toContain('<span style="color: #FB4526; font-size: 60px">新文字</span>') |
| 182 | expect(result).not.toContain('zoom') |
| 183 | }) |
| 184 | |
| 185 | it('can update a bare text node by parent selector and child node index', () => { |
| 186 | const html = ` |
| 187 | <html><body data-page-id="page"> |
| 188 | <p data-block-id="text"><span data-block-id="text-1">南欧</span>旧文字</p> |
| 189 | </body></html> |
| 190 | ` |
| 191 | |
| 192 | const result = patchGenericElementProperties( |
| 193 | html, |
| 194 | 'body[data-page-id="page"] [data-block-id="text"]', |
| 195 | { |
| 196 | text: '新文字', |
| 197 | textTarget: { |
| 198 | type: 'text-node', |
| 199 | parentSelector: 'body[data-page-id="page"] [data-block-id="text"]', |
| 200 | textNodeIndex: 1 |
| 201 | } |
| 202 | } |
| 203 | ) |
| 204 | |
| 205 | expect(result).toContain('<span data-block-id="text-1">南欧</span>新文字') |
| 206 | }) |
| 207 | }) |
| 208 | |
| 209 | describe('patchGenericElementProperties formula', () => { |
| 210 | const formulaHtml = |
| 211 | '<span class="katex"><span class="katex-mathml"><math><semantics><mrow></mrow><annotation encoding="application/x-tex">x^2</annotation></semantics></math></span><span class="katex-html">x2</span></span>' |
| 212 | |
| 213 | it('updates an inline source formula without replacing surrounding text', () => { |
| 214 | const html = ` |
| 215 | <html><body data-page-id="page"> |
| 216 | <p data-block-id="text">函数 \\(x^2\\) 的导数</p> |
| 217 | </body></html> |
| 218 | ` |
| 219 | |
| 220 | const result = patchGenericElementProperties( |
| 221 | html, |
| 222 | 'body[data-page-id="page"] [data-block-id="text"]', |
| 223 | { |
| 224 | formula: { |
| 225 | latex: 'x^3', |
| 226 | html: formulaHtml, |
| 227 | displayMode: false, |
| 228 | originalLatex: 'x^2' |
| 229 | } |
| 230 | } |
| 231 | ) |
| 232 | |
| 233 | expect(result).toContain('函数 \\(x^3\\) 的导数') |
| 234 | }) |
| 235 | |
| 236 | it('persists display mode changes by rewriting formula delimiters', () => { |
| 237 | const html = ` |
| 238 | <html><body data-page-id="page"> |
| 239 | <p data-block-id="text">函数 \\(x^2\\) 的导数</p> |
| 240 | </body></html> |
| 241 | ` |
| 242 | |
| 243 | const result = patchGenericElementProperties( |
| 244 | html, |
| 245 | 'body[data-page-id="page"] [data-block-id="text"]', |
| 246 | { |
| 247 | formula: { |
| 248 | latex: 'x^2', |
| 249 | html: formulaHtml, |
| 250 | displayMode: true, |
| 251 | originalLatex: 'x^2' |
| 252 | } |
| 253 | } |
| 254 | ) |
| 255 | |
| 256 | expect(result).toContain('函数 \\[x^2\\] 的导数') |
| 257 | expect(result).not.toContain('\\(x^2\\)') |
| 258 | }) |
| 259 | |
| 260 | it('does not clear surrounding text when source formula matching fails', () => { |
| 261 | const html = ` |
| 262 | <html><body data-page-id="page"> |
| 263 | <p data-block-id="text">函数 \\(x^2\\) 与 \\(y^2\\) 的导数</p> |
| 264 | </body></html> |
| 265 | ` |
| 266 | |
| 267 | expect(() => |
| 268 | patchGenericElementProperties(html, 'body[data-page-id="page"] [data-block-id="text"]', { |
| 269 | formula: { |
| 270 | latex: 'z^2', |
| 271 | html: formulaHtml, |
| 272 | displayMode: false, |
| 273 | originalLatex: 'missing' |
| 274 | } |
| 275 | }) |
| 276 | ).toThrow('公式定位失败') |
| 277 | }) |
| 278 | |
| 279 | it('writes rendered KaTeX into an empty formula host', () => { |
| 280 | const html = ` |
| 281 | <html><body data-page-id="page"> |
| 282 | <div data-block-id="formula"></div> |
| 283 | </body></html> |
| 284 | ` |
| 285 | |
| 286 | const result = patchGenericElementProperties( |
| 287 | html, |
| 288 | 'body[data-page-id="page"] [data-block-id="formula"]', |
| 289 | { |
| 290 | formula: { |
| 291 | latex: 'x^2', |
| 292 | html: formulaHtml, |
| 293 | displayMode: true, |
| 294 | originalLatex: '' |
| 295 | } |
| 296 | } |
| 297 | ) |
| 298 | |
| 299 | expect(result).toContain('class="katex"') |
| 300 | expect(result).toContain('data-ppt-formula-latex="x^2"') |
| 301 | expect(result).toContain('data-ppt-formula-display="true"') |
| 302 | }) |
| 303 | |
| 304 | it('strips unsafe markup from rendered formula html', () => { |
| 305 | const html = ` |
| 306 | <html><body data-page-id="page"> |
| 307 | <div data-block-id="formula"></div> |
| 308 | </body></html> |
| 309 | ` |
| 310 | |
| 311 | const result = patchGenericElementProperties( |
| 312 | html, |
| 313 | 'body[data-page-id="page"] [data-block-id="formula"]', |
| 314 | { |
| 315 | formula: { |
| 316 | latex: 'x^2', |
| 317 | html: '<span class="katex" onclick="alert(1)"><script>alert(1)</script><span style="background:url(javascript:alert(1))">x2</span></span>', |
| 318 | displayMode: false, |
| 319 | originalLatex: '' |
| 320 | } |
| 321 | } |
| 322 | ) |
| 323 | |
| 324 | expect(result).toContain('class="katex"') |
| 325 | expect(result).not.toContain('<script') |
| 326 | expect(result).not.toContain('onclick') |
| 327 | expect(result).not.toContain('javascript:') |
| 328 | }) |
| 329 | |
| 330 | it('does not persist transient editor classes in formula html', () => { |
| 331 | const html = ` |
| 332 | <html><body data-page-id="page"> |
| 333 | <div data-block-id="formula"></div> |
| 334 | </body></html> |
| 335 | ` |
| 336 | |
| 337 | const result = patchGenericElementProperties( |
| 338 | html, |
| 339 | 'body[data-page-id="page"] [data-block-id="formula"]', |
| 340 | { |
| 341 | formula: { |
| 342 | latex: 'x^2', |
| 343 | html: '<span class="katex arcsin1-presentation-editor-selected ppt-inspector-highlight"><span class="katex-html">x2</span></span>', |
| 344 | displayMode: false, |
| 345 | originalLatex: '' |
| 346 | } |
| 347 | } |
| 348 | ) |
| 349 | |
| 350 | expect(result).toContain('class="katex"') |
| 351 | expect(result).not.toContain('arcsin1-presentation-editor-selected') |
| 352 | expect(result).not.toContain('ppt-inspector-highlight') |
| 353 | }) |
| 354 | }) |
| 355 | |
| 356 | describe('patchGenericElementProperties layer', () => { |
| 357 | it('persists negative z-index and makes static media positionable', () => { |
| 358 | const html = ` |
| 359 | <html><body data-page-id="page"> |
| 360 | <img data-block-id="media" src="./images/a.png" style="width: 100px"> |
| 361 | </body></html> |
| 362 | ` |
| 363 | |
| 364 | const result = patchGenericElementProperties( |
| 365 | html, |
| 366 | 'body[data-page-id="page"] [data-block-id="media"]', |
| 367 | { |
| 368 | style: { zIndex: -1 } |
| 369 | } |
| 370 | ) |
| 371 | |
| 372 | expect(result).toContain('width: 100px; position: relative; z-index: -1') |
| 373 | }) |
| 374 | }) |
| 375 |