| 1 | import path from 'path' |
| 2 | import fs from 'fs' |
| 3 | import { Window } from 'happy-dom' |
| 4 | import { describe, expect, it } from 'vitest' |
| 5 | import { |
| 6 | EDIT_MODE_CONSOLE_PREFIX, |
| 7 | buildEditModeInjectScript, |
| 8 | INSPECTOR_CONSOLE_PREFIX, |
| 9 | buildInspectorInjectScript |
| 10 | } from '@arcsin1/presentation-editor-runtime' |
| 11 | |
| 12 | type Rect = Pick<DOMRect, 'left' | 'top' | 'right' | 'bottom' | 'width' | 'height'> |
| 13 | |
| 14 | function setupInlineTextPage(script: string) { |
| 15 | const window = new Window({ url: 'http://localhost/page.html' }) as unknown as Window & { |
| 16 | ResizeObserver: typeof ResizeObserver |
| 17 | eval: (code: string) => void |
| 18 | } |
| 19 | const { document } = window |
| 20 | const logs: string[] = [] |
| 21 | |
| 22 | document.body.setAttribute('data-page-id', 'page') |
| 23 | document.body.innerHTML = ` |
| 24 | <main class="ppt-page-root" data-ppt-guard-root="1"> |
| 25 | <main data-block-id="content" data-role="content"> |
| 26 | <p data-block-id="text">Alpha <span style="color:#FB4526" data-block-id="text-6">red text</span> omega</p> |
| 27 | </main> |
| 28 | </main> |
| 29 | ` |
| 30 | |
| 31 | const root = document.querySelector('.ppt-page-root') as HTMLElement |
| 32 | const content = document.querySelector('[data-block-id="content"]') as HTMLElement |
| 33 | const paragraph = document.querySelector('[data-block-id="text"]') as HTMLElement |
| 34 | const redSpan = document.querySelector('[data-block-id="text-6"]') as HTMLElement |
| 35 | const rects = new Map<Element, Rect>([ |
| 36 | [root, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 37 | [content, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 38 | [paragraph, { left: 100, top: 100, right: 520, bottom: 130, width: 420, height: 30 }], |
| 39 | [redSpan, { left: 180, top: 100, right: 260, bottom: 130, width: 80, height: 30 }] |
| 40 | ]) |
| 41 | |
| 42 | window.HTMLElement.prototype.getBoundingClientRect = function () { |
| 43 | return rects.get(this) || { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } |
| 44 | } |
| 45 | window.HTMLElement.prototype.getClientRects = function () { |
| 46 | return [this.getBoundingClientRect()] |
| 47 | } |
| 48 | |
| 49 | const mockedDocument = document as Document & { |
| 50 | caretRangeFromPoint: () => { startContainer: ChildNode | null } |
| 51 | } |
| 52 | mockedDocument.elementFromPoint = () => paragraph |
| 53 | mockedDocument.elementsFromPoint = () => [paragraph, content, root] |
| 54 | mockedDocument.caretRangeFromPoint = () => ({ startContainer: redSpan.firstChild }) |
| 55 | window.ResizeObserver = class { |
| 56 | observe() {} |
| 57 | disconnect() {} |
| 58 | } as unknown as typeof ResizeObserver |
| 59 | window.console.log = (value?: unknown) => logs.push(String(value)) |
| 60 | |
| 61 | window.eval(script) |
| 62 | |
| 63 | return { window, paragraph, logs } |
| 64 | } |
| 65 | |
| 66 | function setupOverlappingPage(script: string) { |
| 67 | const window = new Window({ url: 'http://localhost/page.html' }) as unknown as Window & { |
| 68 | ResizeObserver: typeof ResizeObserver |
| 69 | eval: (code: string) => void |
| 70 | } |
| 71 | const { document } = window |
| 72 | const logs: string[] = [] |
| 73 | |
| 74 | document.body.setAttribute('data-page-id', 'page') |
| 75 | document.body.innerHTML = ` |
| 76 | <main class="ppt-page-root" data-ppt-guard-root="1"> |
| 77 | <main data-block-id="content" data-role="content"> |
| 78 | <div style="pointer-events:none" data-block-id="top">top visual layer</div> |
| 79 | <span data-block-id="behind">small behind layer</span> |
| 80 | </main> |
| 81 | </main> |
| 82 | ` |
| 83 | |
| 84 | const root = document.querySelector('.ppt-page-root') as HTMLElement |
| 85 | const content = document.querySelector('[data-block-id="content"]') as HTMLElement |
| 86 | const top = document.querySelector('[data-block-id="top"]') as HTMLElement |
| 87 | const behind = document.querySelector('[data-block-id="behind"]') as HTMLElement |
| 88 | const rects = new Map<Element, Rect>([ |
| 89 | [root, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 90 | [content, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 91 | [top, { left: 100, top: 100, right: 420, bottom: 260, width: 320, height: 160 }], |
| 92 | [behind, { left: 150, top: 120, right: 180, bottom: 145, width: 30, height: 25 }] |
| 93 | ]) |
| 94 | |
| 95 | window.HTMLElement.prototype.getBoundingClientRect = function () { |
| 96 | return rects.get(this) || { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } |
| 97 | } |
| 98 | window.HTMLElement.prototype.getClientRects = function () { |
| 99 | return [this.getBoundingClientRect()] |
| 100 | } |
| 101 | |
| 102 | const mockedDocument = document as Document & { |
| 103 | caretRangeFromPoint: () => null |
| 104 | } |
| 105 | mockedDocument.elementFromPoint = () => behind |
| 106 | mockedDocument.elementsFromPoint = () => |
| 107 | Array.from(document.head.querySelectorAll('style')).some((style) => |
| 108 | style.textContent?.includes('pointer-events: auto') |
| 109 | ) |
| 110 | ? [top, behind, content, root] |
| 111 | : [behind, content, root] |
| 112 | mockedDocument.caretRangeFromPoint = () => null |
| 113 | window.ResizeObserver = class { |
| 114 | observe() {} |
| 115 | disconnect() {} |
| 116 | } as unknown as typeof ResizeObserver |
| 117 | window.console.log = (value?: unknown) => logs.push(String(value)) |
| 118 | |
| 119 | window.eval(script) |
| 120 | |
| 121 | return { window, eventTarget: behind, logs } |
| 122 | } |
| 123 | |
| 124 | function setupMixedInlineParagraphPage(script: string) { |
| 125 | const window = new Window({ url: 'http://localhost/page.html' }) as unknown as Window & { |
| 126 | ResizeObserver: typeof ResizeObserver |
| 127 | eval: (code: string) => void |
| 128 | } |
| 129 | const { document } = window |
| 130 | const logs: string[] = [] |
| 131 | |
| 132 | document.body.setAttribute('data-page-id', 'page') |
| 133 | document.body.innerHTML = ` |
| 134 | <main class="ppt-page-root" data-ppt-guard-root="1"> |
| 135 | <main data-block-id="content" data-role="content"> |
| 136 | <p data-block-id="text"><span data-block-id="text-1">南欧</span>(意、西、希)TFR在1.1-1.2区间徘徊超20年</p> |
| 137 | </main> |
| 138 | </main> |
| 139 | ` |
| 140 | |
| 141 | const root = document.querySelector('.ppt-page-root') as HTMLElement |
| 142 | const content = document.querySelector('[data-block-id="content"]') as HTMLElement |
| 143 | const paragraph = document.querySelector('[data-block-id="text"]') as HTMLElement |
| 144 | const span = document.querySelector('[data-block-id="text-1"]') as HTMLElement |
| 145 | const rects = new Map<Element, Rect>([ |
| 146 | [root, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 147 | [content, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 148 | [paragraph, { left: 100, top: 100, right: 520, bottom: 130, width: 420, height: 30 }], |
| 149 | [span, { left: 100, top: 100, right: 140, bottom: 130, width: 40, height: 30 }] |
| 150 | ]) |
| 151 | |
| 152 | window.HTMLElement.prototype.getBoundingClientRect = function () { |
| 153 | return rects.get(this) || { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } |
| 154 | } |
| 155 | window.HTMLElement.prototype.getClientRects = function () { |
| 156 | return [this.getBoundingClientRect()] |
| 157 | } |
| 158 | |
| 159 | const mockedDocument = document as Document & { |
| 160 | caretRangeFromPoint: () => { startContainer: ChildNode | null } |
| 161 | } |
| 162 | mockedDocument.elementFromPoint = () => paragraph |
| 163 | mockedDocument.elementsFromPoint = () => [paragraph, content, root] |
| 164 | mockedDocument.caretRangeFromPoint = () => ({ startContainer: paragraph.lastChild }) |
| 165 | window.ResizeObserver = class { |
| 166 | observe() {} |
| 167 | disconnect() {} |
| 168 | } as unknown as typeof ResizeObserver |
| 169 | window.console.log = (value?: unknown) => logs.push(String(value)) |
| 170 | |
| 171 | window.eval(script) |
| 172 | |
| 173 | return { window, paragraph, logs } |
| 174 | } |
| 175 | |
| 176 | function setupFormulaPage(script: string) { |
| 177 | const window = new Window({ url: 'http://localhost/page.html' }) as unknown as Window & { |
| 178 | ResizeObserver: typeof ResizeObserver |
| 179 | eval: (code: string) => void |
| 180 | } |
| 181 | const { document } = window |
| 182 | const logs: string[] = [] |
| 183 | |
| 184 | document.body.setAttribute('data-page-id', 'page') |
| 185 | document.body.innerHTML = ` |
| 186 | <main class="ppt-page-root" data-ppt-guard-root="1"> |
| 187 | <main data-block-id="content" data-role="content"> |
| 188 | <div data-block-id="formula-card"> |
| 189 | <span class="katex"> |
| 190 | <span class="katex-mathml"> |
| 191 | <math> |
| 192 | <semantics> |
| 193 | <mrow></mrow> |
| 194 | <annotation encoding="application/x-tex">x^2</annotation> |
| 195 | </semantics> |
| 196 | </math> |
| 197 | </span> |
| 198 | <span class="katex-html">x2</span> |
| 199 | </span> |
| 200 | </div> |
| 201 | </main> |
| 202 | </main> |
| 203 | ` |
| 204 | |
| 205 | const root = document.querySelector('.ppt-page-root') as HTMLElement |
| 206 | const content = document.querySelector('[data-block-id="content"]') as HTMLElement |
| 207 | const card = document.querySelector('[data-block-id="formula-card"]') as HTMLElement |
| 208 | const formula = document.querySelector('.katex') as HTMLElement |
| 209 | const formulaHtml = document.querySelector('.katex-html') as HTMLElement |
| 210 | const annotation = document.querySelector('annotation') as Element |
| 211 | const rects = new Map<Element, Rect>([ |
| 212 | [root, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 213 | [content, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 214 | [card, { left: 100, top: 100, right: 520, bottom: 220, width: 420, height: 120 }], |
| 215 | [formula, { left: 160, top: 130, right: 300, bottom: 180, width: 140, height: 50 }], |
| 216 | [formulaHtml, { left: 160, top: 130, right: 300, bottom: 180, width: 140, height: 50 }], |
| 217 | [annotation, { left: 160, top: 130, right: 300, bottom: 180, width: 140, height: 50 }] |
| 218 | ]) |
| 219 | |
| 220 | window.HTMLElement.prototype.getBoundingClientRect = function () { |
| 221 | return rects.get(this) || { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } |
| 222 | } |
| 223 | window.HTMLElement.prototype.getClientRects = function () { |
| 224 | return [this.getBoundingClientRect()] |
| 225 | } |
| 226 | |
| 227 | const mockedDocument = document as Document & { |
| 228 | caretRangeFromPoint: () => null |
| 229 | } |
| 230 | mockedDocument.elementFromPoint = () => formulaHtml |
| 231 | mockedDocument.elementsFromPoint = () => [formulaHtml, formula, card, content, root] |
| 232 | mockedDocument.caretRangeFromPoint = () => null |
| 233 | window.ResizeObserver = class { |
| 234 | observe() {} |
| 235 | disconnect() {} |
| 236 | } as unknown as typeof ResizeObserver |
| 237 | window.console.log = (value?: unknown) => logs.push(String(value)) |
| 238 | |
| 239 | window.eval(script) |
| 240 | |
| 241 | return { window, formula, formulaHtml, logs } |
| 242 | } |
| 243 | |
| 244 | function setupAnchoredFormulaPage(script: string) { |
| 245 | const result = setupFormulaPage(script) |
| 246 | result.formula.setAttribute('data-block-id', 'formula-1') |
| 247 | return result |
| 248 | } |
| 249 | |
| 250 | function setupFormulaWithoutAnnotationPage(script: string) { |
| 251 | const window = new Window({ url: 'http://localhost/page.html' }) as unknown as Window & { |
| 252 | ResizeObserver: typeof ResizeObserver |
| 253 | eval: (code: string) => void |
| 254 | } |
| 255 | const { document } = window |
| 256 | const logs: string[] = [] |
| 257 | |
| 258 | document.body.setAttribute('data-page-id', 'page') |
| 259 | document.body.innerHTML = ` |
| 260 | <main class="ppt-page-root" data-ppt-guard-root="1"> |
| 261 | <main data-block-id="content" data-role="content"> |
| 262 | <p data-block-id="text">函数 <span class="katex"><span class="katex-html">x2</span></span> 的导数</p> |
| 263 | </main> |
| 264 | </main> |
| 265 | ` |
| 266 | |
| 267 | const root = document.querySelector('.ppt-page-root') as HTMLElement |
| 268 | const content = document.querySelector('[data-block-id="content"]') as HTMLElement |
| 269 | const paragraph = document.querySelector('[data-block-id="text"]') as HTMLElement |
| 270 | const formula = document.querySelector('.katex') as HTMLElement |
| 271 | const formulaHtml = document.querySelector('.katex-html') as HTMLElement |
| 272 | const rects = new Map<Element, Rect>([ |
| 273 | [root, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 274 | [content, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 275 | [paragraph, { left: 100, top: 100, right: 520, bottom: 130, width: 420, height: 30 }], |
| 276 | [formula, { left: 160, top: 100, right: 220, bottom: 130, width: 60, height: 30 }], |
| 277 | [formulaHtml, { left: 160, top: 100, right: 220, bottom: 130, width: 60, height: 30 }] |
| 278 | ]) |
| 279 | |
| 280 | window.HTMLElement.prototype.getBoundingClientRect = function () { |
| 281 | return rects.get(this) || { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } |
| 282 | } |
| 283 | window.HTMLElement.prototype.getClientRects = function () { |
| 284 | return [this.getBoundingClientRect()] |
| 285 | } |
| 286 | |
| 287 | const mockedDocument = document as Document & { |
| 288 | caretRangeFromPoint: () => null |
| 289 | } |
| 290 | mockedDocument.elementFromPoint = () => formulaHtml |
| 291 | mockedDocument.elementsFromPoint = () => [formulaHtml, formula, paragraph, content, root] |
| 292 | mockedDocument.caretRangeFromPoint = () => null |
| 293 | window.ResizeObserver = class { |
| 294 | observe() {} |
| 295 | disconnect() {} |
| 296 | } as unknown as typeof ResizeObserver |
| 297 | window.console.log = (value?: unknown) => logs.push(String(value)) |
| 298 | |
| 299 | window.eval(script) |
| 300 | |
| 301 | return { window, formulaHtml, logs } |
| 302 | } |
| 303 | |
| 304 | function setupDisplayFormulaPage(script: string) { |
| 305 | const window = new Window({ url: 'http://localhost/page.html' }) as unknown as Window & { |
| 306 | ResizeObserver: typeof ResizeObserver |
| 307 | eval: (code: string) => void |
| 308 | } |
| 309 | const { document } = window |
| 310 | const logs: string[] = [] |
| 311 | |
| 312 | document.body.setAttribute('data-page-id', 'page') |
| 313 | document.body.innerHTML = ` |
| 314 | <main class="ppt-page-root" data-ppt-guard-root="1"> |
| 315 | <main data-block-id="content" data-role="content"> |
| 316 | <div data-block-id="formula-card"> |
| 317 | <span class="katex-display"> |
| 318 | <span class="katex"> |
| 319 | <span class="katex-mathml"> |
| 320 | <math> |
| 321 | <semantics> |
| 322 | <mrow></mrow> |
| 323 | <annotation encoding="application/x-tex">x^2</annotation> |
| 324 | </semantics> |
| 325 | </math> |
| 326 | </span> |
| 327 | <span class="katex-html"> |
| 328 | <span class="base">x2</span> |
| 329 | </span> |
| 330 | </span> |
| 331 | </span> |
| 332 | </div> |
| 333 | </main> |
| 334 | </main> |
| 335 | ` |
| 336 | |
| 337 | const root = document.querySelector('.ppt-page-root') as HTMLElement |
| 338 | const content = document.querySelector('[data-block-id="content"]') as HTMLElement |
| 339 | const card = document.querySelector('[data-block-id="formula-card"]') as HTMLElement |
| 340 | const display = document.querySelector('.katex-display') as HTMLElement |
| 341 | const formula = document.querySelector('.katex') as HTMLElement |
| 342 | const formulaHtml = document.querySelector('.katex-html') as HTMLElement |
| 343 | const base = document.querySelector('.base') as HTMLElement |
| 344 | const rects = new Map<Element, Rect>([ |
| 345 | [root, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 346 | [content, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 347 | [card, { left: 100, top: 100, right: 560, bottom: 260, width: 460, height: 160 }], |
| 348 | [display, { left: 120, top: 110, right: 520, bottom: 230, width: 400, height: 120 }], |
| 349 | [formula, { left: 120, top: 110, right: 520, bottom: 230, width: 400, height: 120 }], |
| 350 | [formulaHtml, { left: 210, top: 145, right: 330, bottom: 185, width: 120, height: 40 }], |
| 351 | [base, { left: 210, top: 145, right: 330, bottom: 185, width: 120, height: 40 }] |
| 352 | ]) |
| 353 | |
| 354 | window.HTMLElement.prototype.getBoundingClientRect = function () { |
| 355 | return rects.get(this) || { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } |
| 356 | } |
| 357 | window.HTMLElement.prototype.getClientRects = function () { |
| 358 | return [this.getBoundingClientRect()] |
| 359 | } |
| 360 | |
| 361 | const mockedDocument = document as Document & { |
| 362 | caretRangeFromPoint: () => null |
| 363 | } |
| 364 | mockedDocument.elementFromPoint = () => display |
| 365 | mockedDocument.elementsFromPoint = () => [display, card, content, root] |
| 366 | mockedDocument.caretRangeFromPoint = () => null |
| 367 | window.ResizeObserver = class { |
| 368 | observe() {} |
| 369 | disconnect() {} |
| 370 | } as unknown as typeof ResizeObserver |
| 371 | window.console.log = (value?: unknown) => logs.push(String(value)) |
| 372 | |
| 373 | window.eval(script) |
| 374 | |
| 375 | return { window, display, formula, formulaHtml, logs } |
| 376 | } |
| 377 | |
| 378 | function setupFormulaHostHitPage(script: string) { |
| 379 | const window = new Window({ url: 'http://localhost/page.html' }) as unknown as Window & { |
| 380 | ResizeObserver: typeof ResizeObserver |
| 381 | eval: (code: string) => void |
| 382 | } |
| 383 | const { document } = window |
| 384 | const logs: string[] = [] |
| 385 | |
| 386 | document.body.setAttribute('data-page-id', 'page') |
| 387 | document.body.innerHTML = ` |
| 388 | <main class="ppt-page-root" data-ppt-guard-root="1"> |
| 389 | <main data-block-id="content" data-role="content"> |
| 390 | <div data-block-id="formula-card"> |
| 391 | <span data-block-id="formula-label">GAUSSIAN INTEGRAL</span> |
| 392 | <span class="katex" data-ppt-formula-latex="\\int_0^\\infty e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2}"> |
| 393 | <span class="katex-mathml"> |
| 394 | <math> |
| 395 | <semantics> |
| 396 | <mrow></mrow> |
| 397 | <annotation encoding="application/x-tex">\\int_0^\\infty e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2}</annotation> |
| 398 | </semantics> |
| 399 | </math> |
| 400 | </span> |
| 401 | <span class="katex-html"> |
| 402 | <span class="base">integral glyphs</span> |
| 403 | </span> |
| 404 | </span> |
| 405 | </div> |
| 406 | </main> |
| 407 | </main> |
| 408 | ` |
| 409 | |
| 410 | const root = document.querySelector('.ppt-page-root') as HTMLElement |
| 411 | const content = document.querySelector('[data-block-id="content"]') as HTMLElement |
| 412 | const card = document.querySelector('[data-block-id="formula-card"]') as HTMLElement |
| 413 | const label = document.querySelector('[data-block-id="formula-label"]') as HTMLElement |
| 414 | const formula = document.querySelector('.katex') as HTMLElement |
| 415 | const formulaHtml = document.querySelector('.katex-html') as HTMLElement |
| 416 | const base = document.querySelector('.base') as HTMLElement |
| 417 | const rects = new Map<Element, Rect>([ |
| 418 | [root, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 419 | [content, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 420 | [card, { left: 80, top: 70, right: 750, bottom: 220, width: 670, height: 150 }], |
| 421 | [label, { left: 82, top: 92, right: 748, bottom: 112, width: 666, height: 20 }], |
| 422 | [formula, { left: 280, top: 140, right: 540, bottom: 195, width: 260, height: 55 }], |
| 423 | [formulaHtml, { left: 280, top: 140, right: 540, bottom: 195, width: 260, height: 55 }], |
| 424 | [base, { left: 280, top: 140, right: 540, bottom: 195, width: 260, height: 55 }] |
| 425 | ]) |
| 426 | |
| 427 | window.HTMLElement.prototype.getBoundingClientRect = function () { |
| 428 | return rects.get(this) || { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } |
| 429 | } |
| 430 | window.HTMLElement.prototype.getClientRects = function () { |
| 431 | return [this.getBoundingClientRect()] |
| 432 | } |
| 433 | |
| 434 | const mockedDocument = document as Document & { |
| 435 | caretRangeFromPoint: () => null |
| 436 | } |
| 437 | mockedDocument.elementFromPoint = () => card |
| 438 | mockedDocument.elementsFromPoint = () => [card, content, root] |
| 439 | mockedDocument.caretRangeFromPoint = () => null |
| 440 | window.ResizeObserver = class { |
| 441 | observe() {} |
| 442 | disconnect() {} |
| 443 | } as unknown as typeof ResizeObserver |
| 444 | window.console.log = (value?: unknown) => logs.push(String(value)) |
| 445 | |
| 446 | window.eval(script) |
| 447 | |
| 448 | return { window, card, formula, logs } |
| 449 | } |
| 450 | |
| 451 | function setupChartPage(script: string) { |
| 452 | const window = new Window({ url: 'http://localhost/page.html' }) as unknown as Window & { |
| 453 | ResizeObserver: typeof ResizeObserver |
| 454 | eval: (code: string) => void |
| 455 | } |
| 456 | const { document } = window |
| 457 | const logs: string[] = [] |
| 458 | |
| 459 | document.body.setAttribute('data-page-id', 'page') |
| 460 | document.body.innerHTML = ` |
| 461 | <main class="ppt-page-root" data-ppt-guard-root="1"> |
| 462 | <main data-block-id="content" data-role="content"> |
| 463 | <div data-block-id="chart-frame" class="ppt-chart-frame"> |
| 464 | <canvas id="chart-canvas"></canvas> |
| 465 | </div> |
| 466 | </main> |
| 467 | </main> |
| 468 | ` |
| 469 | |
| 470 | const root = document.querySelector('.ppt-page-root') as HTMLElement |
| 471 | const content = document.querySelector('[data-block-id="content"]') as HTMLElement |
| 472 | const frame = document.querySelector('[data-block-id="chart-frame"]') as HTMLElement |
| 473 | const canvas = document.querySelector('#chart-canvas') as HTMLElement |
| 474 | const rects = new Map<Element, Rect>([ |
| 475 | [root, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 476 | [content, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 477 | [frame, { left: 120, top: 120, right: 420, bottom: 320, width: 300, height: 200 }], |
| 478 | [canvas, { left: 120, top: 120, right: 420, bottom: 320, width: 300, height: 200 }] |
| 479 | ]) |
| 480 | |
| 481 | window.HTMLElement.prototype.getBoundingClientRect = function () { |
| 482 | return rects.get(this) || { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } |
| 483 | } |
| 484 | window.HTMLElement.prototype.getClientRects = function () { |
| 485 | return [this.getBoundingClientRect()] |
| 486 | } |
| 487 | |
| 488 | const mockedDocument = document as Document & { |
| 489 | caretRangeFromPoint: () => null |
| 490 | } |
| 491 | mockedDocument.elementFromPoint = () => canvas |
| 492 | mockedDocument.elementsFromPoint = () => [canvas, frame, content, root] |
| 493 | mockedDocument.caretRangeFromPoint = () => null |
| 494 | window.ResizeObserver = class { |
| 495 | observe() {} |
| 496 | disconnect() {} |
| 497 | } as unknown as typeof ResizeObserver |
| 498 | window.console.log = (value?: unknown) => logs.push(String(value)) |
| 499 | |
| 500 | window.eval(script) |
| 501 | |
| 502 | return { window, frame, canvas, logs } |
| 503 | } |
| 504 | |
| 505 | function setupSvgPage(script: string) { |
| 506 | const window = new Window({ url: 'http://localhost/page.html' }) as unknown as Window & { |
| 507 | ResizeObserver: typeof ResizeObserver |
| 508 | eval: (code: string) => void |
| 509 | } |
| 510 | const { document } = window |
| 511 | const logs: string[] = [] |
| 512 | |
| 513 | document.body.setAttribute('data-page-id', 'page') |
| 514 | document.body.innerHTML = ` |
| 515 | <main class="ppt-page-root" data-ppt-guard-root="1"> |
| 516 | <main data-block-id="content" data-role="content"> |
| 517 | <div data-block-id="logo" class="logo-host"> |
| 518 | <svg viewBox="0 0 100 100"> |
| 519 | <path d="M10 10 L90 10 L50 90 Z"></path> |
| 520 | </svg> |
| 521 | </div> |
| 522 | </main> |
| 523 | </main> |
| 524 | ` |
| 525 | |
| 526 | const root = document.querySelector('.ppt-page-root') as HTMLElement |
| 527 | const content = document.querySelector('[data-block-id="content"]') as HTMLElement |
| 528 | const logo = document.querySelector('[data-block-id="logo"]') as HTMLElement |
| 529 | const svg = document.querySelector('svg') as HTMLElement |
| 530 | const path = document.querySelector('path') as Element |
| 531 | const rects = new Map<Element, Rect>([ |
| 532 | [root, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 533 | [content, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 534 | [logo, { left: 180, top: 110, right: 340, bottom: 270, width: 160, height: 160 }], |
| 535 | [svg, { left: 180, top: 110, right: 340, bottom: 270, width: 160, height: 160 }], |
| 536 | [path, { left: 190, top: 120, right: 330, bottom: 260, width: 140, height: 140 }] |
| 537 | ]) |
| 538 | |
| 539 | window.HTMLElement.prototype.getBoundingClientRect = function () { |
| 540 | return rects.get(this) || { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } |
| 541 | } |
| 542 | window.SVGElement.prototype.getBoundingClientRect = function () { |
| 543 | return rects.get(this) || { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } |
| 544 | } |
| 545 | window.HTMLElement.prototype.getClientRects = function () { |
| 546 | return [this.getBoundingClientRect()] |
| 547 | } |
| 548 | window.SVGElement.prototype.getClientRects = function () { |
| 549 | return [this.getBoundingClientRect()] |
| 550 | } |
| 551 | |
| 552 | const mockedDocument = document as Document & { |
| 553 | caretRangeFromPoint: () => null |
| 554 | } |
| 555 | mockedDocument.elementFromPoint = () => path |
| 556 | mockedDocument.elementsFromPoint = () => [path as unknown as Element, svg, logo, content, root] |
| 557 | mockedDocument.caretRangeFromPoint = () => null |
| 558 | window.ResizeObserver = class { |
| 559 | observe() {} |
| 560 | disconnect() {} |
| 561 | } as unknown as typeof ResizeObserver |
| 562 | window.console.log = (value?: unknown) => logs.push(String(value)) |
| 563 | |
| 564 | window.eval(script) |
| 565 | |
| 566 | return { window, logo, path, logs } |
| 567 | } |
| 568 | |
| 569 | function setupGeneratedBackgroundPage(script: string) { |
| 570 | const window = new Window({ url: 'http://localhost/page.html' }) as unknown as Window & { |
| 571 | ResizeObserver: typeof ResizeObserver |
| 572 | eval: (code: string) => void |
| 573 | } |
| 574 | const { document } = window |
| 575 | const logs: string[] = [] |
| 576 | |
| 577 | document.body.setAttribute('data-page-id', 'page') |
| 578 | document.body.innerHTML = ` |
| 579 | <main class="ppt-page-root" data-ppt-guard-root="1"> |
| 580 | <main data-block-id="content" data-role="content"> |
| 581 | <div data-block-id="hero">Hero content</div> |
| 582 | <img data-block-id="bg" data-ppt-generated-background="1" src="./bg.png" alt=""> |
| 583 | </main> |
| 584 | </main> |
| 585 | ` |
| 586 | |
| 587 | const root = document.querySelector('.ppt-page-root') as HTMLElement |
| 588 | const content = document.querySelector('[data-block-id="content"]') as HTMLElement |
| 589 | const hero = document.querySelector('[data-block-id="hero"]') as HTMLElement |
| 590 | const background = document.querySelector('[data-ppt-generated-background="1"]') as HTMLElement |
| 591 | const rects = new Map<Element, Rect>([ |
| 592 | [root, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 593 | [content, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 594 | [hero, { left: 120, top: 120, right: 520, bottom: 280, width: 400, height: 160 }], |
| 595 | [background, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }] |
| 596 | ]) |
| 597 | |
| 598 | window.HTMLElement.prototype.getBoundingClientRect = function () { |
| 599 | return rects.get(this) || { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } |
| 600 | } |
| 601 | window.HTMLElement.prototype.getClientRects = function () { |
| 602 | return [this.getBoundingClientRect()] |
| 603 | } |
| 604 | |
| 605 | const mockedDocument = document as Document & { |
| 606 | caretRangeFromPoint: () => null |
| 607 | } |
| 608 | mockedDocument.elementFromPoint = () => |
| 609 | background.style.visibility === 'hidden' ? hero : background |
| 610 | mockedDocument.elementsFromPoint = () => |
| 611 | background.style.visibility === 'hidden' |
| 612 | ? [hero, content, root] |
| 613 | : Array.from(document.head.querySelectorAll('style')).some((style) => |
| 614 | style.textContent?.includes('pointer-events: auto') |
| 615 | ) |
| 616 | ? [background, hero, content, root] |
| 617 | : [background, content, root] |
| 618 | mockedDocument.caretRangeFromPoint = () => null |
| 619 | window.ResizeObserver = class { |
| 620 | observe() {} |
| 621 | disconnect() {} |
| 622 | } as unknown as typeof ResizeObserver |
| 623 | window.console.log = (value?: unknown) => logs.push(String(value)) |
| 624 | |
| 625 | window.eval(script) |
| 626 | |
| 627 | return { window, hero, background, logs } |
| 628 | } |
| 629 | |
| 630 | function setupOverflowBoxPage(script: string) { |
| 631 | const window = new Window({ url: 'http://localhost/page.html' }) as unknown as Window & { |
| 632 | ResizeObserver: typeof ResizeObserver |
| 633 | eval: (code: string) => void |
| 634 | } |
| 635 | const { document } = window |
| 636 | const logs: string[] = [] |
| 637 | |
| 638 | document.body.setAttribute('data-page-id', 'page') |
| 639 | document.body.innerHTML = ` |
| 640 | <main class="ppt-page-root" data-ppt-guard-root="1"> |
| 641 | <main data-block-id="content" data-role="content"> |
| 642 | <div data-block-id="card"> |
| 643 | <span class="overflow-child">Overflowing child</span> |
| 644 | </div> |
| 645 | </main> |
| 646 | </main> |
| 647 | ` |
| 648 | |
| 649 | const root = document.querySelector('.ppt-page-root') as HTMLElement |
| 650 | const content = document.querySelector('[data-block-id="content"]') as HTMLElement |
| 651 | const card = document.querySelector('[data-block-id="card"]') as HTMLElement |
| 652 | const child = document.querySelector('.overflow-child') as HTMLElement |
| 653 | const rects = new Map<Element, Rect>([ |
| 654 | [root, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 655 | [content, { left: 0, top: 0, right: 1000, bottom: 600, width: 1000, height: 600 }], |
| 656 | [card, { left: 180, top: 120, right: 280, bottom: 160, width: 100, height: 40 }], |
| 657 | [child, { left: 170, top: 110, right: 360, bottom: 210, width: 190, height: 100 }] |
| 658 | ]) |
| 659 | |
| 660 | window.HTMLElement.prototype.getBoundingClientRect = function () { |
| 661 | return rects.get(this) || { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } |
| 662 | } |
| 663 | window.HTMLElement.prototype.getClientRects = function () { |
| 664 | return [this.getBoundingClientRect()] |
| 665 | } |
| 666 | |
| 667 | const mockedDocument = document as Document & { |
| 668 | caretRangeFromPoint: () => null |
| 669 | } |
| 670 | mockedDocument.elementFromPoint = () => card |
| 671 | mockedDocument.elementsFromPoint = () => [card, child, content, root] |
| 672 | mockedDocument.caretRangeFromPoint = () => null |
| 673 | window.ResizeObserver = class { |
| 674 | observe() {} |
| 675 | disconnect() {} |
| 676 | } as unknown as typeof ResizeObserver |
| 677 | window.console.log = (value?: unknown) => logs.push(String(value)) |
| 678 | |
| 679 | window.eval(script) |
| 680 | |
| 681 | return { window, card, logs } |
| 682 | } |
| 683 | |
| 684 | function readPayload(logs: string[], prefix: string) { |
| 685 | const log = logs.findLast((item) => item.startsWith(prefix)) |
| 686 | expect(log).toBeTruthy() |
| 687 | return JSON.parse(String(log).slice(prefix.length)) as { selector?: string; elementTag?: string } |
| 688 | } |
| 689 | |
| 690 | describe('preview text hit selection', () => { |
| 691 | it('animation-select inspector mode does not freeze motion', () => { |
| 692 | const script = buildInspectorInjectScript({ mode: 'animation-select' }) |
| 693 | expect(script).toContain('MODE = "animation-select"') |
| 694 | expect(script).toContain('window.__pptInspectorRestoreSelection') |
| 695 | expect(script).toContain('shouldFreezeMotion') |
| 696 | }) |
| 697 | |
| 698 | it('preview iframe does not install a click animation bridge', async () => { |
| 699 | const source = fs.readFileSync( |
| 700 | path.resolve(__dirname, '../../../src/renderer/src/components/preview/PreviewIframe.tsx'), |
| 701 | 'utf-8' |
| 702 | ) |
| 703 | expect(source).not.toContain('preview-click-animation') |
| 704 | expect(source).toContain("url.searchParams.set('pptPlayback', '0')") |
| 705 | expect(source).toContain("url.searchParams.set('print', '1')") |
| 706 | }) |
| 707 | |
| 708 | it('inspector selects the inline span under the text caret point', () => { |
| 709 | const { window, paragraph, logs } = setupInlineTextPage(buildInspectorInjectScript()) |
| 710 | |
| 711 | paragraph.dispatchEvent( |
| 712 | new window.MouseEvent('click', { |
| 713 | bubbles: true, |
| 714 | cancelable: true, |
| 715 | clientX: 190, |
| 716 | clientY: 115 |
| 717 | }) |
| 718 | ) |
| 719 | |
| 720 | const payload = readPayload(logs, INSPECTOR_CONSOLE_PREFIX) |
| 721 | expect(payload.elementTag).toBe('span') |
| 722 | expect(payload.selector).toBe('body[data-page-id="page"] [data-block-id="text-6"]') |
| 723 | }) |
| 724 | |
| 725 | it('edit mode selects the inline span under the text caret point', () => { |
| 726 | const { window, paragraph, logs } = setupInlineTextPage(buildEditModeInjectScript()) |
| 727 | |
| 728 | paragraph.dispatchEvent( |
| 729 | new window.PointerEvent('pointerdown', { |
| 730 | bubbles: true, |
| 731 | cancelable: true, |
| 732 | button: 0, |
| 733 | clientX: 190, |
| 734 | clientY: 115, |
| 735 | pointerId: 1 |
| 736 | }) |
| 737 | ) |
| 738 | paragraph.dispatchEvent( |
| 739 | new window.PointerEvent('pointerup', { |
| 740 | bubbles: true, |
| 741 | cancelable: true, |
| 742 | button: 0, |
| 743 | clientX: 190, |
| 744 | clientY: 115, |
| 745 | pointerId: 1 |
| 746 | }) |
| 747 | ) |
| 748 | |
| 749 | const payload = readPayload(logs, EDIT_MODE_CONSOLE_PREFIX) |
| 750 | expect(payload.elementTag).toBe('span') |
| 751 | expect(payload.selector).toBe('body[data-page-id="page"] [data-block-id="text-6"]') |
| 752 | }) |
| 753 | |
| 754 | it('edit mode does not treat a long pointer release as a selection click', () => { |
| 755 | const { window, paragraph, logs } = setupInlineTextPage(buildEditModeInjectScript()) |
| 756 | |
| 757 | paragraph.dispatchEvent( |
| 758 | new window.PointerEvent('pointerdown', { |
| 759 | bubbles: true, |
| 760 | cancelable: true, |
| 761 | button: 0, |
| 762 | clientX: 190, |
| 763 | clientY: 115, |
| 764 | pointerId: 1 |
| 765 | }) |
| 766 | ) |
| 767 | paragraph.dispatchEvent( |
| 768 | new window.PointerEvent('pointerup', { |
| 769 | bubbles: true, |
| 770 | cancelable: true, |
| 771 | button: 0, |
| 772 | clientX: 240, |
| 773 | clientY: 115, |
| 774 | pointerId: 1 |
| 775 | }) |
| 776 | ) |
| 777 | |
| 778 | expect(logs.some((item) => item.startsWith(EDIT_MODE_CONSOLE_PREFIX))).toBe(false) |
| 779 | }) |
| 780 | |
| 781 | it('inspector follows the browser hit-test stack before geometry size', () => { |
| 782 | const { window, eventTarget, logs } = setupOverlappingPage(buildInspectorInjectScript()) |
| 783 | |
| 784 | eventTarget.dispatchEvent( |
| 785 | new window.MouseEvent('click', { |
| 786 | bubbles: true, |
| 787 | cancelable: true, |
| 788 | clientX: 160, |
| 789 | clientY: 130 |
| 790 | }) |
| 791 | ) |
| 792 | |
| 793 | const payload = readPayload(logs, INSPECTOR_CONSOLE_PREFIX) |
| 794 | expect(payload.selector).toBe('body[data-page-id="page"] [data-block-id="top"]') |
| 795 | }) |
| 796 | |
| 797 | it('edit mode follows the browser hit-test stack before geometry size', () => { |
| 798 | const { window, eventTarget, logs } = setupOverlappingPage(buildEditModeInjectScript()) |
| 799 | |
| 800 | eventTarget.dispatchEvent( |
| 801 | new window.PointerEvent('pointerdown', { |
| 802 | bubbles: true, |
| 803 | cancelable: true, |
| 804 | button: 0, |
| 805 | clientX: 160, |
| 806 | clientY: 130, |
| 807 | pointerId: 1 |
| 808 | }) |
| 809 | ) |
| 810 | eventTarget.dispatchEvent( |
| 811 | new window.PointerEvent('pointerup', { |
| 812 | bubbles: true, |
| 813 | cancelable: true, |
| 814 | button: 0, |
| 815 | clientX: 160, |
| 816 | clientY: 130, |
| 817 | pointerId: 1 |
| 818 | }) |
| 819 | ) |
| 820 | |
| 821 | const payload = readPayload(logs, EDIT_MODE_CONSOLE_PREFIX) |
| 822 | expect(payload.selector).toBe('body[data-page-id="page"] [data-block-id="top"]') |
| 823 | }) |
| 824 | |
| 825 | it('edit mode treats mixed inline paragraphs as editable text', () => { |
| 826 | const { window, paragraph, logs } = setupMixedInlineParagraphPage(buildEditModeInjectScript()) |
| 827 | |
| 828 | paragraph.dispatchEvent( |
| 829 | new window.PointerEvent('pointerdown', { |
| 830 | bubbles: true, |
| 831 | cancelable: true, |
| 832 | button: 0, |
| 833 | clientX: 180, |
| 834 | clientY: 115, |
| 835 | pointerId: 1 |
| 836 | }) |
| 837 | ) |
| 838 | paragraph.dispatchEvent( |
| 839 | new window.PointerEvent('pointerup', { |
| 840 | bubbles: true, |
| 841 | cancelable: true, |
| 842 | button: 0, |
| 843 | clientX: 180, |
| 844 | clientY: 115, |
| 845 | pointerId: 1 |
| 846 | }) |
| 847 | ) |
| 848 | |
| 849 | const payload = readPayload(logs, EDIT_MODE_CONSOLE_PREFIX) as { |
| 850 | isText?: boolean |
| 851 | selector?: string |
| 852 | text?: string |
| 853 | html?: string |
| 854 | textTarget?: { |
| 855 | parentSelector?: string |
| 856 | textNodeIndex?: number |
| 857 | text?: string |
| 858 | } |
| 859 | } |
| 860 | expect(payload.selector).toBe('body[data-page-id="page"] [data-block-id="text"]') |
| 861 | expect(payload.isText).toBe(true) |
| 862 | expect(payload.text).toBe('南欧(意、西、希)TFR在1.1-1.2区间徘徊超20年') |
| 863 | expect(payload.html).toBe( |
| 864 | '<span data-block-id="text-1">南欧</span>(意、西、希)TFR在1.1-1.2区间徘徊超20年' |
| 865 | ) |
| 866 | expect(payload.textTarget).toMatchObject({ |
| 867 | parentSelector: 'body[data-page-id="page"] [data-block-id="text"]', |
| 868 | textNodeIndex: 1, |
| 869 | text: '(意、西、希)TFR在1.1-1.2区间徘徊超20年' |
| 870 | }) |
| 871 | }) |
| 872 | |
| 873 | it('inspector selects the KaTeX node instead of its formula container', () => { |
| 874 | const { window, formula, formulaHtml, logs } = setupFormulaPage(buildInspectorInjectScript()) |
| 875 | |
| 876 | formulaHtml.dispatchEvent( |
| 877 | new window.MouseEvent('click', { |
| 878 | bubbles: true, |
| 879 | cancelable: true, |
| 880 | clientX: 180, |
| 881 | clientY: 145 |
| 882 | }) |
| 883 | ) |
| 884 | |
| 885 | const payload = readPayload(logs, INSPECTOR_CONSOLE_PREFIX) |
| 886 | expect(payload.elementTag).toBe('span') |
| 887 | expect(payload.selector).not.toContain('formula-card') |
| 888 | expect(window.document.querySelector(payload.selector || '')).toBe(formula) |
| 889 | }) |
| 890 | |
| 891 | it('animation-select includes formula metadata for runtime-rendered formulas', () => { |
| 892 | const { window, formulaHtml, logs } = setupFormulaPage( |
| 893 | buildInspectorInjectScript({ mode: 'animation-select' }) |
| 894 | ) |
| 895 | |
| 896 | formulaHtml.dispatchEvent( |
| 897 | new window.MouseEvent('click', { |
| 898 | bubbles: true, |
| 899 | cancelable: true, |
| 900 | clientX: 180, |
| 901 | clientY: 145 |
| 902 | }) |
| 903 | ) |
| 904 | |
| 905 | const payload = readPayload(logs, INSPECTOR_CONSOLE_PREFIX) as { |
| 906 | mode?: string |
| 907 | formula?: { latex?: string; html?: string; displayMode?: boolean } |
| 908 | } |
| 909 | expect(payload.mode).toBe('animation-select') |
| 910 | expect(payload.formula).toMatchObject({ |
| 911 | latex: 'x^2', |
| 912 | displayMode: false |
| 913 | }) |
| 914 | expect(payload.formula?.html).toContain('class="katex"') |
| 915 | }) |
| 916 | |
| 917 | it('inspector selects the formula when hit testing returns the formula host', () => { |
| 918 | const { window, card, formula, logs } = setupFormulaHostHitPage(buildInspectorInjectScript()) |
| 919 | |
| 920 | card.dispatchEvent( |
| 921 | new window.MouseEvent('mousemove', { |
| 922 | bubbles: true, |
| 923 | cancelable: true, |
| 924 | clientX: 360, |
| 925 | clientY: 166 |
| 926 | }) |
| 927 | ) |
| 928 | card.dispatchEvent( |
| 929 | new window.MouseEvent('click', { |
| 930 | bubbles: true, |
| 931 | cancelable: true, |
| 932 | clientX: 360, |
| 933 | clientY: 166 |
| 934 | }) |
| 935 | ) |
| 936 | |
| 937 | const payload = readPayload(logs, INSPECTOR_CONSOLE_PREFIX) |
| 938 | expect(payload.selector).not.toContain('formula-card') |
| 939 | expect(window.document.querySelector(payload.selector || '')).toBe(formula) |
| 940 | const overlay = window.document.getElementById('ppt-inspector-highlight-overlay') as HTMLElement |
| 941 | expect(overlay.style.left).toBe('276px') |
| 942 | expect(overlay.style.top).toBe('136px') |
| 943 | expect(overlay.style.width).toBe('268px') |
| 944 | expect(overlay.style.height).toBe('63px') |
| 945 | }) |
| 946 | |
| 947 | it('inspector does not steal the formula host when the point is outside formula glyphs', () => { |
| 948 | const { window, card, formula, logs } = setupFormulaHostHitPage(buildInspectorInjectScript()) |
| 949 | |
| 950 | card.dispatchEvent( |
| 951 | new window.MouseEvent('click', { |
| 952 | bubbles: true, |
| 953 | cancelable: true, |
| 954 | clientX: 360, |
| 955 | clientY: 102 |
| 956 | }) |
| 957 | ) |
| 958 | |
| 959 | const payload = readPayload(logs, INSPECTOR_CONSOLE_PREFIX) |
| 960 | expect(window.document.querySelector(payload.selector || '')).toBe(card) |
| 961 | expect(window.document.querySelector(payload.selector || '')).not.toBe(formula) |
| 962 | }) |
| 963 | |
| 964 | it('inspector selects the chart frame when clicking the canvas', () => { |
| 965 | const { window, frame, canvas, logs } = setupChartPage(buildInspectorInjectScript()) |
| 966 | |
| 967 | canvas.dispatchEvent( |
| 968 | new window.MouseEvent('click', { |
| 969 | bubbles: true, |
| 970 | cancelable: true, |
| 971 | clientX: 200, |
| 972 | clientY: 180 |
| 973 | }) |
| 974 | ) |
| 975 | |
| 976 | const payload = readPayload(logs, INSPECTOR_CONSOLE_PREFIX) |
| 977 | expect(payload.elementTag).toBe('div') |
| 978 | expect(payload.selector).toBe('body[data-page-id="page"] [data-block-id="chart-frame"]') |
| 979 | expect(window.document.querySelector(payload.selector || '')).toBe(frame) |
| 980 | }) |
| 981 | |
| 982 | it('inspector keeps generated backgrounds from stealing selection', () => { |
| 983 | const { window, hero, background, logs } = setupGeneratedBackgroundPage( |
| 984 | buildInspectorInjectScript() |
| 985 | ) |
| 986 | |
| 987 | background.dispatchEvent( |
| 988 | new window.MouseEvent('click', { |
| 989 | bubbles: true, |
| 990 | cancelable: true, |
| 991 | clientX: 220, |
| 992 | clientY: 180 |
| 993 | }) |
| 994 | ) |
| 995 | |
| 996 | const payload = readPayload(logs, INSPECTOR_CONSOLE_PREFIX) |
| 997 | expect(payload.selector).toBe('body[data-page-id="page"] [data-block-id="hero"]') |
| 998 | expect(window.document.querySelector(payload.selector || '')).toBe(hero) |
| 999 | }) |
| 1000 | |
| 1001 | it('inspector selects the svg owner instead of the nested svg path', () => { |
| 1002 | const { window, logo, path, logs } = setupSvgPage(buildInspectorInjectScript()) |
| 1003 | |
| 1004 | path.dispatchEvent( |
| 1005 | new window.MouseEvent('click', { |
| 1006 | bubbles: true, |
| 1007 | cancelable: true, |
| 1008 | clientX: 240, |
| 1009 | clientY: 180 |
| 1010 | }) |
| 1011 | ) |
| 1012 | |
| 1013 | const payload = readPayload(logs, INSPECTOR_CONSOLE_PREFIX) |
| 1014 | expect(payload.elementTag).toBe('div') |
| 1015 | expect(payload.selector).toBe('body[data-page-id="page"] [data-block-id="logo"]') |
| 1016 | expect(window.document.querySelector(payload.selector || '')).toBe(logo) |
| 1017 | }) |
| 1018 | |
| 1019 | it('edit mode selects the KaTeX node instead of its formula container', () => { |
| 1020 | const { window, formula, formulaHtml, logs } = setupFormulaPage(buildEditModeInjectScript()) |
| 1021 | |
| 1022 | formulaHtml.dispatchEvent( |
| 1023 | new window.PointerEvent('pointerdown', { |
| 1024 | bubbles: true, |
| 1025 | cancelable: true, |
| 1026 | button: 0, |
| 1027 | clientX: 180, |
| 1028 | clientY: 145, |
| 1029 | pointerId: 1 |
| 1030 | }) |
| 1031 | ) |
| 1032 | formulaHtml.dispatchEvent( |
| 1033 | new window.PointerEvent('pointerup', { |
| 1034 | bubbles: true, |
| 1035 | cancelable: true, |
| 1036 | button: 0, |
| 1037 | clientX: 180, |
| 1038 | clientY: 145, |
| 1039 | pointerId: 1 |
| 1040 | }) |
| 1041 | ) |
| 1042 | |
| 1043 | const payload = readPayload(logs, EDIT_MODE_CONSOLE_PREFIX) |
| 1044 | expect(payload.elementTag).toBe('span') |
| 1045 | expect(payload.selector).not.toContain('formula-card') |
| 1046 | expect(window.document.querySelector(payload.selector || '')).toBe(formula) |
| 1047 | expect(payload.kind).toBe('formula') |
| 1048 | expect(payload.capabilities).toContain('formula') |
| 1049 | expect(payload.snapshot?.formula?.html).toContain('class="katex"') |
| 1050 | expect(payload.snapshot?.formula?.html).not.toContain('arcsin1-presentation-editor-selected') |
| 1051 | }) |
| 1052 | |
| 1053 | it('edit mode selects the formula when hit testing returns the formula host', () => { |
| 1054 | const { window, card, formula, logs } = setupFormulaHostHitPage(buildEditModeInjectScript()) |
| 1055 | |
| 1056 | card.dispatchEvent( |
| 1057 | new window.PointerEvent('pointerdown', { |
| 1058 | bubbles: true, |
| 1059 | cancelable: true, |
| 1060 | button: 0, |
| 1061 | clientX: 360, |
| 1062 | clientY: 166, |
| 1063 | pointerId: 1 |
| 1064 | }) |
| 1065 | ) |
| 1066 | card.dispatchEvent( |
| 1067 | new window.PointerEvent('pointerup', { |
| 1068 | bubbles: true, |
| 1069 | cancelable: true, |
| 1070 | button: 0, |
| 1071 | clientX: 360, |
| 1072 | clientY: 166, |
| 1073 | pointerId: 1 |
| 1074 | }) |
| 1075 | ) |
| 1076 | |
| 1077 | const payload = readPayload(logs, EDIT_MODE_CONSOLE_PREFIX) |
| 1078 | expect(payload.selector).not.toContain('formula-card') |
| 1079 | expect(window.document.querySelector(payload.selector || '')).toBe(formula) |
| 1080 | expect(payload.kind).toBe('formula') |
| 1081 | const overlay = window.document.getElementById('arcsin1-presentation-editor-resize-overlay') as HTMLElement |
| 1082 | expect(overlay.style.left).toBe('280px') |
| 1083 | expect(overlay.style.top).toBe('140px') |
| 1084 | expect(overlay.style.width).toBe('260px') |
| 1085 | expect(overlay.style.height).toBe('55px') |
| 1086 | }) |
| 1087 | |
| 1088 | it('edit mode does not steal the formula host when the point is outside formula glyphs', () => { |
| 1089 | const { window, card, formula, logs } = setupFormulaHostHitPage(buildEditModeInjectScript()) |
| 1090 | |
| 1091 | card.dispatchEvent( |
| 1092 | new window.PointerEvent('pointerdown', { |
| 1093 | bubbles: true, |
| 1094 | cancelable: true, |
| 1095 | button: 0, |
| 1096 | clientX: 360, |
| 1097 | clientY: 102, |
| 1098 | pointerId: 1 |
| 1099 | }) |
| 1100 | ) |
| 1101 | card.dispatchEvent( |
| 1102 | new window.PointerEvent('pointerup', { |
| 1103 | bubbles: true, |
| 1104 | cancelable: true, |
| 1105 | button: 0, |
| 1106 | clientX: 360, |
| 1107 | clientY: 102, |
| 1108 | pointerId: 1 |
| 1109 | }) |
| 1110 | ) |
| 1111 | |
| 1112 | const payload = readPayload(logs, EDIT_MODE_CONSOLE_PREFIX) |
| 1113 | expect(window.document.querySelector(payload.selector || '')).toBe(card) |
| 1114 | expect(window.document.querySelector(payload.selector || '')).not.toBe(formula) |
| 1115 | }) |
| 1116 | |
| 1117 | it('edit mode selects the chart frame when clicking the canvas', () => { |
| 1118 | const { window, frame, canvas, logs } = setupChartPage(buildEditModeInjectScript()) |
| 1119 | |
| 1120 | canvas.dispatchEvent( |
| 1121 | new window.PointerEvent('pointerdown', { |
| 1122 | bubbles: true, |
| 1123 | cancelable: true, |
| 1124 | button: 0, |
| 1125 | clientX: 200, |
| 1126 | clientY: 180, |
| 1127 | pointerId: 1 |
| 1128 | }) |
| 1129 | ) |
| 1130 | canvas.dispatchEvent( |
| 1131 | new window.PointerEvent('pointerup', { |
| 1132 | bubbles: true, |
| 1133 | cancelable: true, |
| 1134 | button: 0, |
| 1135 | clientX: 200, |
| 1136 | clientY: 180, |
| 1137 | pointerId: 1 |
| 1138 | }) |
| 1139 | ) |
| 1140 | |
| 1141 | const payload = readPayload(logs, EDIT_MODE_CONSOLE_PREFIX) |
| 1142 | expect(payload.elementTag).toBe('div') |
| 1143 | expect(payload.selector).toBe('body[data-page-id="page"] [data-block-id="chart-frame"]') |
| 1144 | expect(window.document.querySelector(payload.selector || '')).toBe(frame) |
| 1145 | }) |
| 1146 | |
| 1147 | it('edit mode selects the svg owner instead of the nested svg path', () => { |
| 1148 | const { window, logo, path, logs } = setupSvgPage(buildEditModeInjectScript()) |
| 1149 | |
| 1150 | path.dispatchEvent( |
| 1151 | new window.PointerEvent('pointerdown', { |
| 1152 | bubbles: true, |
| 1153 | cancelable: true, |
| 1154 | button: 0, |
| 1155 | clientX: 240, |
| 1156 | clientY: 180, |
| 1157 | pointerId: 1 |
| 1158 | }) |
| 1159 | ) |
| 1160 | path.dispatchEvent( |
| 1161 | new window.PointerEvent('pointerup', { |
| 1162 | bubbles: true, |
| 1163 | cancelable: true, |
| 1164 | button: 0, |
| 1165 | clientX: 240, |
| 1166 | clientY: 180, |
| 1167 | pointerId: 1 |
| 1168 | }) |
| 1169 | ) |
| 1170 | |
| 1171 | const payload = readPayload(logs, EDIT_MODE_CONSOLE_PREFIX) |
| 1172 | expect(payload.elementTag).toBe('div') |
| 1173 | expect(payload.selector).toBe('body[data-page-id="page"] [data-block-id="logo"]') |
| 1174 | expect(window.document.querySelector(payload.selector || '')).toBe(logo) |
| 1175 | }) |
| 1176 | |
| 1177 | it('keeps the formula block id after live formula replacement', () => { |
| 1178 | const { window, formulaHtml, logs } = setupAnchoredFormulaPage(buildEditModeInjectScript()) |
| 1179 | |
| 1180 | formulaHtml.dispatchEvent( |
| 1181 | new window.PointerEvent('pointerdown', { |
| 1182 | bubbles: true, |
| 1183 | cancelable: true, |
| 1184 | button: 0, |
| 1185 | clientX: 180, |
| 1186 | clientY: 145, |
| 1187 | pointerId: 1 |
| 1188 | }) |
| 1189 | ) |
| 1190 | formulaHtml.dispatchEvent( |
| 1191 | new window.PointerEvent('pointerup', { |
| 1192 | bubbles: true, |
| 1193 | cancelable: true, |
| 1194 | button: 0, |
| 1195 | clientX: 180, |
| 1196 | clientY: 145, |
| 1197 | pointerId: 1 |
| 1198 | }) |
| 1199 | ) |
| 1200 | |
| 1201 | const payload = readPayload(logs, EDIT_MODE_CONSOLE_PREFIX) |
| 1202 | expect(payload.selector).toBe('body[data-page-id="page"] [data-block-id="formula-1"]') |
| 1203 | |
| 1204 | ;( |
| 1205 | window as Window & { |
| 1206 | __pptEditModeLiveUpdate: ( |
| 1207 | selector: string, |
| 1208 | patch: { formula: { latex: string; html: string; displayMode: boolean } } |
| 1209 | ) => void |
| 1210 | } |
| 1211 | ).__pptEditModeLiveUpdate(payload.selector, { |
| 1212 | formula: { |
| 1213 | latex: 'x^3', |
| 1214 | html: '<span class="katex"><span class="katex-mathml"><math><semantics><mrow></mrow><annotation encoding="application/x-tex">x^3</annotation></semantics></math></span><span class="katex-html">x3</span></span>', |
| 1215 | displayMode: false |
| 1216 | } |
| 1217 | }) |
| 1218 | |
| 1219 | const updatedFormula = window.document.querySelector( |
| 1220 | 'body[data-page-id="page"] [data-block-id="formula-1"]' |
| 1221 | ) |
| 1222 | expect(updatedFormula).toBeTruthy() |
| 1223 | expect(updatedFormula?.classList.contains('katex')).toBe(true) |
| 1224 | expect(updatedFormula?.getAttribute('data-ppt-formula-latex')).toBe('x^3') |
| 1225 | }) |
| 1226 | |
| 1227 | it('does not treat surrounding text as latex when KaTeX has no annotation', () => { |
| 1228 | const { window, formulaHtml, logs } = setupFormulaWithoutAnnotationPage( |
| 1229 | buildEditModeInjectScript() |
| 1230 | ) |
| 1231 | |
| 1232 | formulaHtml.dispatchEvent( |
| 1233 | new window.PointerEvent('pointerdown', { |
| 1234 | bubbles: true, |
| 1235 | cancelable: true, |
| 1236 | button: 0, |
| 1237 | clientX: 180, |
| 1238 | clientY: 115, |
| 1239 | pointerId: 1 |
| 1240 | }) |
| 1241 | ) |
| 1242 | formulaHtml.dispatchEvent( |
| 1243 | new window.PointerEvent('pointerup', { |
| 1244 | bubbles: true, |
| 1245 | cancelable: true, |
| 1246 | button: 0, |
| 1247 | clientX: 180, |
| 1248 | clientY: 115, |
| 1249 | pointerId: 1 |
| 1250 | }) |
| 1251 | ) |
| 1252 | |
| 1253 | const payload = readPayload(logs, EDIT_MODE_CONSOLE_PREFIX) as { |
| 1254 | snapshot?: { formula?: { latex?: string } } |
| 1255 | } |
| 1256 | expect(payload.snapshot?.formula).toBeUndefined() |
| 1257 | }) |
| 1258 | |
| 1259 | it('edit mode overlay uses tight KaTeX bounds instead of the inflated wrapper box', () => { |
| 1260 | const { window, formulaHtml } = setupDisplayFormulaPage(buildEditModeInjectScript()) |
| 1261 | |
| 1262 | formulaHtml.dispatchEvent( |
| 1263 | new window.PointerEvent('pointerdown', { |
| 1264 | bubbles: true, |
| 1265 | cancelable: true, |
| 1266 | button: 0, |
| 1267 | clientX: 240, |
| 1268 | clientY: 160, |
| 1269 | pointerId: 1 |
| 1270 | }) |
| 1271 | ) |
| 1272 | formulaHtml.dispatchEvent( |
| 1273 | new window.PointerEvent('pointerup', { |
| 1274 | bubbles: true, |
| 1275 | cancelable: true, |
| 1276 | button: 0, |
| 1277 | clientX: 240, |
| 1278 | clientY: 160, |
| 1279 | pointerId: 1 |
| 1280 | }) |
| 1281 | ) |
| 1282 | |
| 1283 | const overlay = window.document.getElementById('arcsin1-presentation-editor-resize-overlay') as HTMLElement |
| 1284 | expect(overlay).toBeTruthy() |
| 1285 | expect(overlay.style.left).toBe('210px') |
| 1286 | expect(overlay.style.top).toBe('145px') |
| 1287 | expect(overlay.style.width).toBe('120px') |
| 1288 | expect(overlay.style.height).toBe('40px') |
| 1289 | }) |
| 1290 | |
| 1291 | it('inspector overlay keeps display formulas aligned to the rendered glyph bounds', () => { |
| 1292 | const { window, formulaHtml, logs } = setupDisplayFormulaPage(buildInspectorInjectScript()) |
| 1293 | |
| 1294 | formulaHtml.dispatchEvent( |
| 1295 | new window.MouseEvent('mousemove', { |
| 1296 | bubbles: true, |
| 1297 | cancelable: true, |
| 1298 | clientX: 240, |
| 1299 | clientY: 160 |
| 1300 | }) |
| 1301 | ) |
| 1302 | formulaHtml.dispatchEvent( |
| 1303 | new window.MouseEvent('click', { |
| 1304 | bubbles: true, |
| 1305 | cancelable: true, |
| 1306 | clientX: 240, |
| 1307 | clientY: 160 |
| 1308 | }) |
| 1309 | ) |
| 1310 | |
| 1311 | const payload = readPayload(logs, INSPECTOR_CONSOLE_PREFIX) |
| 1312 | expect(payload.selector).not.toContain('formula-card') |
| 1313 | const overlay = window.document.getElementById('ppt-inspector-highlight-overlay') as HTMLElement |
| 1314 | expect(overlay).toBeTruthy() |
| 1315 | expect(overlay.style.left).toBe('206px') |
| 1316 | expect(overlay.style.top).toBe('141px') |
| 1317 | expect(overlay.style.width).toBe('128px') |
| 1318 | expect(overlay.style.height).toBe('48px') |
| 1319 | }) |
| 1320 | |
| 1321 | it('edit mode keeps non-formula overlays aligned to the selected element box', () => { |
| 1322 | const { window, card, logs } = setupOverflowBoxPage(buildEditModeInjectScript()) |
| 1323 | |
| 1324 | card.dispatchEvent( |
| 1325 | new window.PointerEvent('pointerdown', { |
| 1326 | bubbles: true, |
| 1327 | cancelable: true, |
| 1328 | button: 0, |
| 1329 | clientX: 220, |
| 1330 | clientY: 140, |
| 1331 | pointerId: 1 |
| 1332 | }) |
| 1333 | ) |
| 1334 | card.dispatchEvent( |
| 1335 | new window.PointerEvent('pointerup', { |
| 1336 | bubbles: true, |
| 1337 | cancelable: true, |
| 1338 | button: 0, |
| 1339 | clientX: 220, |
| 1340 | clientY: 140, |
| 1341 | pointerId: 1 |
| 1342 | }) |
| 1343 | ) |
| 1344 | |
| 1345 | const payload = readPayload(logs, EDIT_MODE_CONSOLE_PREFIX) as { |
| 1346 | viewportBounds?: { width?: number; height?: number } |
| 1347 | } |
| 1348 | expect(payload.viewportBounds).toMatchObject({ width: 100, height: 40 }) |
| 1349 | const overlay = window.document.getElementById('arcsin1-presentation-editor-resize-overlay') as HTMLElement |
| 1350 | expect(overlay).toBeTruthy() |
| 1351 | expect(overlay.style.left).toBe('180px') |
| 1352 | expect(overlay.style.top).toBe('120px') |
| 1353 | expect(overlay.style.width).toBe('100px') |
| 1354 | expect(overlay.style.height).toBe('40px') |
| 1355 | }) |
| 1356 | |
| 1357 | it('inspector keeps non-formula highlights aligned to the selected element box', () => { |
| 1358 | const { window, card, logs } = setupOverflowBoxPage(buildInspectorInjectScript()) |
| 1359 | |
| 1360 | card.dispatchEvent( |
| 1361 | new window.MouseEvent('mousemove', { |
| 1362 | bubbles: true, |
| 1363 | cancelable: true, |
| 1364 | clientX: 220, |
| 1365 | clientY: 140 |
| 1366 | }) |
| 1367 | ) |
| 1368 | card.dispatchEvent( |
| 1369 | new window.MouseEvent('click', { |
| 1370 | bubbles: true, |
| 1371 | cancelable: true, |
| 1372 | clientX: 220, |
| 1373 | clientY: 140 |
| 1374 | }) |
| 1375 | ) |
| 1376 | |
| 1377 | const payload = readPayload(logs, INSPECTOR_CONSOLE_PREFIX) as { |
| 1378 | bounds?: { width?: number; height?: number } |
| 1379 | } |
| 1380 | expect(payload.bounds).toMatchObject({ width: 100, height: 40 }) |
| 1381 | const overlay = window.document.getElementById('ppt-inspector-highlight-overlay') as HTMLElement |
| 1382 | expect(overlay).toBeTruthy() |
| 1383 | expect(overlay.style.left).toBe('176px') |
| 1384 | expect(overlay.style.top).toBe('116px') |
| 1385 | expect(overlay.style.width).toBe('108px') |
| 1386 | expect(overlay.style.height).toBe('48px') |
| 1387 | }) |
| 1388 | |
| 1389 | it('inspector keeps the selected highlight after hover moves away', () => { |
| 1390 | const { window, card, logs } = setupOverflowBoxPage(buildInspectorInjectScript()) |
| 1391 | |
| 1392 | card.dispatchEvent( |
| 1393 | new window.MouseEvent('mousemove', { |
| 1394 | bubbles: true, |
| 1395 | cancelable: true, |
| 1396 | clientX: 220, |
| 1397 | clientY: 140 |
| 1398 | }) |
| 1399 | ) |
| 1400 | card.dispatchEvent( |
| 1401 | new window.MouseEvent('click', { |
| 1402 | bubbles: true, |
| 1403 | cancelable: true, |
| 1404 | clientX: 220, |
| 1405 | clientY: 140 |
| 1406 | }) |
| 1407 | ) |
| 1408 | |
| 1409 | const payload = readPayload(logs, INSPECTOR_CONSOLE_PREFIX) as { |
| 1410 | bounds?: { width?: number; height?: number } |
| 1411 | } |
| 1412 | expect(payload.bounds).toMatchObject({ width: 100, height: 40 }) |
| 1413 | |
| 1414 | const overlay = window.document.getElementById('ppt-inspector-highlight-overlay') as HTMLElement |
| 1415 | expect(overlay).toBeTruthy() |
| 1416 | expect(overlay.style.left).toBe('176px') |
| 1417 | expect(overlay.style.top).toBe('116px') |
| 1418 | expect(overlay.style.width).toBe('108px') |
| 1419 | expect(overlay.style.height).toBe('48px') |
| 1420 | |
| 1421 | const mockedDocument = window.document as Document & { |
| 1422 | elementFromPoint: () => Element | null |
| 1423 | elementsFromPoint: () => Element[] |
| 1424 | } |
| 1425 | mockedDocument.elementFromPoint = () => null |
| 1426 | mockedDocument.elementsFromPoint = () => [] |
| 1427 | |
| 1428 | card.dispatchEvent( |
| 1429 | new window.MouseEvent('mousemove', { |
| 1430 | bubbles: true, |
| 1431 | cancelable: true, |
| 1432 | clientX: 12, |
| 1433 | clientY: 12 |
| 1434 | }) |
| 1435 | ) |
| 1436 | |
| 1437 | expect(overlay.style.left).toBe('176px') |
| 1438 | expect(overlay.style.top).toBe('116px') |
| 1439 | expect(overlay.style.width).toBe('108px') |
| 1440 | expect(overlay.style.height).toBe('48px') |
| 1441 | }) |
| 1442 | }) |
| 1443 |