| ... | ... | @@ -0,0 +1,723 @@ |
| 1 | import { |
| 2 | getSharedHighlighter, |
| 3 | VirtualizedFile, |
| 4 | Virtualizer, |
| 5 | type FileContents, |
| 6 | } from "@pierre/diffs"; |
| 7 | import { Editor, TextDocument } from "@pierre/diffs/editor"; |
| 8 | import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 9 | |
| 10 | const renderingManagerUrl = new URL( |
| 11 | "./managers/UniversalRenderingManager.js", |
| 12 | import.meta.resolve("@pierre/diffs"), |
| 13 | ); |
| 14 | const { clearRenderQueue } = (await import(/* @vite-ignore */ renderingManagerUrl.href)) as { |
| 15 | clearRenderQueue(): void; |
| 16 | }; |
| 17 | |
| 18 | // Layout measurements are controlled here. The real reconciler, document and |
| 19 | // renderer calculate positions. This does not simulate native CSS wrapping. |
| 20 | class MeasuredElement { |
| 21 | static geometryReads = 0; |
| 22 | children: MeasuredElement[] = []; |
| 23 | dataset: Record<string, string> = {}; |
| 24 | nextElementSibling: MeasuredElement | null = null; |
| 25 | width = 283; |
| 26 | |
| 27 | constructor(readonly height = 0) {} |
| 28 | |
| 29 | getBoundingClientRect() { |
| 30 | MeasuredElement.geometryReads += 1; |
| 31 | return { top: 0, height: this.height, width: this.width }; |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | class MeasuredCodeElement extends MeasuredElement { |
| 36 | readonly tagName = "CODE"; |
| 37 | |
| 38 | get firstElementChild() { |
| 39 | return this.children[0] ?? null; |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | const observers: RecordedResizeObserver[] = []; |
| 44 | const animationFrames = new Map<number, FrameRequestCallback>(); |
| 45 | let nextFrameId = 0; |
| 46 | |
| 47 | class RecordedResizeObserver { |
| 48 | readonly targets = new Set<Element>(); |
| 49 | |
| 50 | constructor(readonly callback: ResizeObserverCallback) { |
| 51 | observers.push(this); |
| 52 | } |
| 53 | |
| 54 | observe(target: Element) { |
| 55 | this.targets.add(target); |
| 56 | } |
| 57 | |
| 58 | unobserve(target: Element) { |
| 59 | this.targets.delete(target); |
| 60 | } |
| 61 | |
| 62 | disconnect() { |
| 63 | this.targets.clear(); |
| 64 | } |
| 65 | |
| 66 | deliver(target: HTMLElement) { |
| 67 | const rect = target.getBoundingClientRect(); |
| 68 | const size = { inlineSize: rect.width, blockSize: rect.height }; |
| 69 | this.callback( |
| 70 | [ |
| 71 | { |
| 72 | target, |
| 73 | contentRect: rect, |
| 74 | contentBoxSize: [size], |
| 75 | borderBoxSize: [size], |
| 76 | devicePixelContentBoxSize: [size], |
| 77 | }, |
| 78 | ], |
| 79 | this as unknown as ResizeObserver, |
| 80 | ); |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | function drainRenderFrames() { |
| 85 | const errors = vi.spyOn(console, "error"); |
| 86 | try { |
| 87 | for (let frame = 0; animationFrames.size > 0; frame += 1) { |
| 88 | if (frame === 10) throw new Error("The production render queue did not settle"); |
| 89 | const callbacks = [...animationFrames.values()]; |
| 90 | animationFrames.clear(); |
| 91 | for (const callback of callbacks) callback(frame); |
| 92 | } |
| 93 | expect(errors).not.toHaveBeenCalled(); |
| 94 | } finally { |
| 95 | errors.mockRestore(); |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | function measuredElement(element: MeasuredElement): HTMLElement { |
| 100 | return element as unknown as HTMLElement; |
| 101 | } |
| 102 | |
| 103 | class LayoutVirtualizer extends Virtualizer { |
| 104 | override getOffsetInScrollContainer(_element: HTMLElement) { |
| 105 | return 0; |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | class MeasuredFile extends VirtualizedFile { |
| 110 | override top = 0; |
| 111 | |
| 112 | override attachEditor(editor: Parameters<VirtualizedFile["attachEditor"]>[0]) { |
| 113 | this.editor = editor; |
| 114 | return () => { |
| 115 | this.editor = undefined; |
| 116 | }; |
| 117 | } |
| 118 | |
| 119 | async initialize(file: FileContents) { |
| 120 | this.prepareCodeViewItem(file, 0); |
| 121 | await this.fileRenderer.initializeHighlighter(); |
| 122 | expect( |
| 123 | this.fileRenderer.renderFile(file, { |
| 124 | startingLine: 5950, |
| 125 | totalLines: 51, |
| 126 | bufferBefore: 0, |
| 127 | bufferAfter: 0, |
| 128 | }), |
| 129 | ).toBeDefined(); |
| 130 | this.fileContainer = measuredElement(new MeasuredElement()); |
| 131 | } |
| 132 | |
| 133 | measure( |
| 134 | rows: ReadonlyArray<readonly [lineIndex: number, height: number]>, |
| 135 | contentWidth = 226.25, |
| 136 | ) { |
| 137 | const content = new MeasuredElement(); |
| 138 | content.width = contentWidth; |
| 139 | content.children = rows.map(([lineIndex, height]) => { |
| 140 | const row = new MeasuredElement(height); |
| 141 | row.dataset.lineIndex = String(lineIndex); |
| 142 | return row; |
| 143 | }); |
| 144 | const code = new MeasuredCodeElement(); |
| 145 | code.width = contentWidth + 56.75; |
| 146 | code.children = [new MeasuredElement(), content]; |
| 147 | this.code = measuredElement(code); |
| 148 | this.reconcileHeights(); |
| 149 | } |
| 150 | |
| 151 | resizeContent(contentWidth: number, codeWidth = contentWidth + 56.75) { |
| 152 | const code = this.code; |
| 153 | const content = code?.children[1]; |
| 154 | if (!(code instanceof MeasuredElement) || !(content instanceof MeasuredElement)) { |
| 155 | throw new Error("Expected measured code and content"); |
| 156 | } |
| 157 | code.width = codeWidth; |
| 158 | content.width = contentWidth; |
| 159 | } |
| 160 | |
| 161 | observeLayout() { |
| 162 | const pre = new MeasuredElement(); |
| 163 | if (!(this.code instanceof MeasuredElement)) throw new Error("Expected measured code"); |
| 164 | pre.children = [this.code]; |
| 165 | this.resizeManager.setup(measuredElement(pre) as HTMLPreElement, { |
| 166 | disableAnnotations: true, |
| 167 | columnVariables: "measure", |
| 168 | }); |
| 169 | const code = this.code; |
| 170 | const observer = observers.find((candidate) => candidate.targets.has(code)); |
| 171 | if (observer === undefined) throw new Error("The real resize manager did not observe code"); |
| 172 | return () => observer.deliver(code); |
| 173 | } |
| 174 | |
| 175 | dispose() { |
| 176 | this.fileContainer = undefined; |
| 177 | this.code = undefined; |
| 178 | this.cleanUp(); |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | const instances: MeasuredFile[] = []; |
| 183 | const editors: Editor<undefined>[] = []; |
| 184 | |
| 185 | beforeAll(async () => { |
| 186 | await getSharedHighlighter({ |
| 187 | themes: ["pierre-dark"], |
| 188 | langs: ["text"], |
| 189 | preferredHighlighter: "shiki-wasm", |
| 190 | }); |
| 191 | }); |
| 192 | |
| 193 | beforeEach(() => { |
| 194 | observers.length = 0; |
| 195 | animationFrames.clear(); |
| 196 | MeasuredElement.geometryReads = 0; |
| 197 | vi.stubGlobal("HTMLElement", MeasuredElement); |
| 198 | vi.stubGlobal("Document", MeasuredElement); |
| 199 | vi.stubGlobal("ResizeObserver", RecordedResizeObserver); |
| 200 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { |
| 201 | const id = ++nextFrameId; |
| 202 | animationFrames.set(id, callback); |
| 203 | return id; |
| 204 | }); |
| 205 | vi.stubGlobal("cancelAnimationFrame", (id: number) => animationFrames.delete(id)); |
| 206 | }); |
| 207 | |
| 208 | afterEach(() => { |
| 209 | for (const editor of editors.splice(0)) editor.cleanUp(); |
| 210 | for (const instance of instances.splice(0)) instance.dispose(); |
| 211 | clearRenderQueue(); |
| 212 | animationFrames.clear(); |
| 213 | vi.unstubAllGlobals(); |
| 214 | }); |
| 215 | |
| 216 | async function makeFixture( |
| 217 | overflow: "wrap" | "scroll" = "wrap", |
| 218 | lineCount = 6001, |
| 219 | contentWidth = 226.25, |
| 220 | ) { |
| 221 | const contents = Array.from({ length: lineCount }, (_, index) => `line ${index}`).join("\n"); |
| 222 | const file: FileContents = { |
| 223 | name: "wrapped.txt", |
| 224 | contents, |
| 225 | cacheKey: `wrapped:${overflow}`, |
| 226 | lang: "text", |
| 227 | }; |
| 228 | const document = new TextDocument(file.name, contents, "text"); |
| 229 | const instance = new MeasuredFile( |
| 230 | { |
| 231 | overflow, |
| 232 | disableFileHeader: true, |
| 233 | theme: "pierre-dark", |
| 234 | preferredHighlighter: "shiki-wasm", |
| 235 | useTokenTransformer: true, |
| 236 | controlledSelection: true, |
| 237 | }, |
| 238 | new LayoutVirtualizer(), |
| 239 | ); |
| 240 | instances.push(instance); |
| 241 | await instance.initialize(file); |
| 242 | instance.measure( |
| 243 | [ |
| 244 | [0, 80], |
| 245 | [120, 60], |
| 246 | [4999, 100], |
| 247 | [5000, 80], |
| 248 | [5999, 100], |
| 249 | [6000, 60], |
| 250 | ], |
| 251 | contentWidth, |
| 252 | ); |
| 253 | const apply = (change: { startLine: number } | undefined, passStartLine = true) => { |
| 254 | if (change === undefined) throw new Error("Expected a document change"); |
| 255 | file.contents = document.getText(); |
| 256 | instance.applyDocumentChange( |
| 257 | document, |
| 258 | undefined, |
| 259 | false, |
| 260 | passStartLine ? change.startLine : undefined, |
| 261 | ); |
| 262 | }; |
| 263 | const append = () => { |
| 264 | const position = document.positionAt(document.getText().length); |
| 265 | apply(document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }])); |
| 266 | }; |
| 267 | return { instance, document, file, apply, append }; |
| 268 | } |
| 269 | |
| 270 | describe("wrapped editor document changes", () => { |
| 271 | it("preserves the position above an EOF insertion across layout checkpoints", async () => { |
| 272 | const { instance, document, append } = await makeFixture(); |
| 273 | const previousLastLine = document.lineCount; |
| 274 | const before = instance.getLinePosition(previousLastLine); |
| 275 | expect(before).toEqual({ top: 120328, height: 60 }); |
| 276 | const viewport = { top: before!.top - 100, bottom: before!.top + 80 }; |
| 277 | expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2156 }); |
| 278 | |
| 279 | append(); |
| 280 | |
| 281 | expect(document.lineCount).toBe(previousLastLine + 1); |
| 282 | expect(instance.getLinePosition(previousLastLine)).toEqual({ top: before!.top, height: 20 }); |
| 283 | expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120348, height: 20 }); |
| 284 | expect(instance.getVirtualizedHeight()).toBe(120376); |
| 285 | expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2136 }); |
| 286 | }); |
| 287 | |
| 288 | it("invalidates changed and shifted rows after an insertion in the middle", async () => { |
| 289 | const { instance, document, apply } = await makeFixture(); |
| 290 | const before = instance.getLinePosition(5001); |
| 291 | const position = { line: 5000, character: 2 }; |
| 292 | apply(document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }])); |
| 293 | |
| 294 | expect(instance.getLinePosition(5001)).toEqual({ top: before!.top, height: 20 }); |
| 295 | expect(instance.getLineHeight(4999)).toBe(100); |
| 296 | expect(instance.getLineHeight(5000)).toBe(20); |
| 297 | expect(instance.getLineHeight(5999)).toBe(20); |
| 298 | expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120208, height: 20 }); |
| 299 | }); |
| 300 | |
| 301 | it("keeps preceding measurements when a deletion crosses a checkpoint", async () => { |
| 302 | const { instance, document, apply } = await makeFixture(); |
| 303 | const before = instance.getLinePosition(5000); |
| 304 | apply( |
| 305 | document.applyEdits([ |
| 306 | { |
| 307 | range: { start: { line: 4999, character: 2 }, end: { line: 5001, character: 2 } }, |
| 308 | newText: "", |
| 309 | }, |
| 310 | ]), |
| 311 | ); |
| 312 | |
| 313 | expect(document.lineCount).toBe(5999); |
| 314 | expect(instance.getLinePosition(5000)).toEqual({ top: before!.top, height: 20 }); |
| 315 | expect(instance.getLineHeight(120)).toBe(60); |
| 316 | expect(instance.getLineHeight(4999)).toBe(20); |
| 317 | expect(instance.getLineHeight(6000)).toBe(20); |
| 318 | expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120068, height: 20 }); |
| 319 | }); |
| 320 | |
| 321 | it("uses the earliest changed line for edits at multiple selections", async () => { |
| 322 | const { instance, document, apply } = await makeFixture(); |
| 323 | const before = instance.getLinePosition(121); |
| 324 | apply( |
| 325 | document.applyEdits( |
| 326 | [120, 5000].map((line) => ({ |
| 327 | range: { start: { line, character: 2 }, end: { line, character: 2 } }, |
| 328 | newText: "\n", |
| 329 | })), |
| 330 | ), |
| 331 | ); |
| 332 | |
| 333 | expect(document.lineCount).toBe(6003); |
| 334 | expect(instance.getLinePosition(121)).toEqual({ top: before!.top, height: 20 }); |
| 335 | expect(instance.getLineHeight(0)).toBe(80); |
| 336 | expect(instance.getLineHeight(120)).toBe(20); |
| 337 | expect(instance.getLineHeight(4999)).toBe(20); |
| 338 | }); |
| 339 | |
| 340 | it("retains the unchanged prefix through repeated Enter, undo and redo", async () => { |
| 341 | const { instance, document, apply, append } = await makeFixture(); |
| 342 | const before = instance.getLinePosition(6001); |
| 343 | for (let count = 0; count < 60; count += 1) append(); |
| 344 | expect(document.lineCount).toBe(6061); |
| 345 | expect(instance.getLinePosition(6001)?.top).toBe(before!.top); |
| 346 | apply(document.undo()?.[0]); |
| 347 | expect(instance.getLinePosition(6001)?.top).toBe(before!.top); |
| 348 | apply(document.redo()?.[0]); |
| 349 | expect(document.lineCount).toBe(6061); |
| 350 | expect(instance.getLinePosition(6001)?.top).toBe(before!.top); |
| 351 | }); |
| 352 | |
| 353 | it("keeps unwrapped positions unchanged", async () => { |
| 354 | const { instance, append } = await makeFixture("scroll"); |
| 355 | const before = instance.getLinePosition(6001); |
| 356 | append(); |
| 357 | expect(instance.getLinePosition(6001)).toEqual(before); |
| 358 | expect(instance.getLinePosition(6002)).toEqual({ top: 120028, height: 20 }); |
| 359 | }); |
| 360 | |
| 361 | it("fully invalidates measurements when the first changed line is unknown", async () => { |
| 362 | const { instance, document, apply } = await makeFixture(); |
| 363 | const position = document.positionAt(document.getText().length); |
| 364 | apply( |
| 365 | document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }]), |
| 366 | false, |
| 367 | ); |
| 368 | expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); |
| 369 | expect(instance.getLineHeight(0)).toBe(20); |
| 370 | }); |
| 371 | |
| 372 | it("still discards all measured rows after a metric change", async () => { |
| 373 | const { instance, file, append } = await makeFixture(); |
| 374 | append(); |
| 375 | instance.setMetrics({ hunkLineCount: 50, lineHeight: 24, diffHeaderHeight: 44, spacing: 8 }); |
| 376 | instance.prepareCodeViewItem(file, 0); |
| 377 | expect(instance.getLinePosition(6001)).toEqual({ top: 144008, height: 24 }); |
| 378 | }); |
| 379 | |
| 380 | it("still discards all measured rows when annotations change", async () => { |
| 381 | const { instance, file, append } = await makeFixture(); |
| 382 | append(); |
| 383 | instance.setLineAnnotations([{ lineNumber: 10, metadata: undefined }]); |
| 384 | instance.prepareCodeViewItem(file, 0); |
| 385 | expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); |
| 386 | }); |
| 387 | }); |
| 388 | |
| 389 | describe("wrapped measurement widths", () => { |
| 390 | it.each([ |
| 391 | [226.25, 482.25], |
| 392 | [482.25, 226.25], |
| 393 | ])( |
| 394 | "drops offscreen measurements when content changes from %spx to %spx", |
| 395 | async (before, after) => { |
| 396 | const { instance } = await makeFixture("wrap", 6001, before); |
| 397 | instance.measure([[6000, 60]], after); |
| 398 | expect(instance.getLineHeight(0)).toBe(20); |
| 399 | expect(instance.getLineHeight(120)).toBe(20); |
| 400 | expect(instance.getLineHeight(6000)).toBe(60); |
| 401 | expect(instance.getVirtualizedHeight()).toBe(120076); |
| 402 | }, |
| 403 | ); |
| 404 | |
| 405 | it.each([ |
| 406 | [226.25, 482.25], |
| 407 | [482.25, 226.25], |
| 408 | ])( |
| 409 | "does not retain old-width prefix heights after a %spx to %spx resize and edit", |
| 410 | async (before, after) => { |
| 411 | const { instance, append } = await makeFixture("wrap", 6001, before); |
| 412 | instance.measure([[6000, 60]], after); |
| 413 | append(); |
| 414 | expect(instance.getLineHeight(0)).toBe(20); |
| 415 | expect(instance.getLineHeight(120)).toBe(20); |
| 416 | expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); |
| 417 | }, |
| 418 | ); |
| 419 | |
| 420 | it("repairs an edit before resize delivery when the real resize and render queues drain", async () => { |
| 421 | const { instance, append } = await makeFixture(); |
| 422 | instance.measure([[6000, 60]]); |
| 423 | const deliverResize = instance.observeLayout(); |
| 424 | instance.resizeContent(482.25); |
| 425 | const readsBeforeEdit = MeasuredElement.geometryReads; |
| 426 | append(); |
| 427 | expect(MeasuredElement.geometryReads).toBe(readsBeforeEdit); |
| 428 | // No synchronous geometry read: the resize entry owns invalidation. |
| 429 | expect(instance.getLineHeight(0)).toBe(80); |
| 430 | deliverResize(); |
| 431 | drainRenderFrames(); |
| 432 | expect(instance.getLineHeight(0)).toBe(20); |
| 433 | expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 60 }); |
| 434 | }); |
| 435 | |
| 436 | it("keeps measured prefixes through same-width reconciliation and editing", async () => { |
| 437 | const { instance, append } = await makeFixture(); |
| 438 | instance.measure([[6000, 60]]); |
| 439 | append(); |
| 440 | expect(instance.getLineHeight(0)).toBe(80); |
| 441 | expect(instance.getLineHeight(120)).toBe(60); |
| 442 | expect(instance.getLinePosition(6001)).toEqual({ top: 120328, height: 20 }); |
| 443 | }); |
| 444 | |
| 445 | it("preserves measurements on first and repeated same-width resize deliveries", async () => { |
| 446 | const { instance } = await makeFixture(); |
| 447 | const before = instance.getVirtualizedHeight(); |
| 448 | const deliverResize = instance.observeLayout(); |
| 449 | deliverResize(); |
| 450 | deliverResize(); |
| 451 | drainRenderFrames(); |
| 452 | expect(instance.getLineHeight(0)).toBe(80); |
| 453 | expect(instance.getVirtualizedHeight()).toBe(before); |
| 454 | }); |
| 455 | |
| 456 | it.each([ |
| 457 | [226.25, 482.25], |
| 458 | [482.25, 226.25], |
| 459 | ])("handles a %spx to %spx resize before first observer delivery", async (before, after) => { |
| 460 | const { instance } = await makeFixture("wrap", 6001, before); |
| 461 | instance.measure([[6000, 20]], before); |
| 462 | const deliverResize = instance.observeLayout(); |
| 463 | instance.resizeContent(after); |
| 464 | deliverResize(); |
| 465 | drainRenderFrames(); |
| 466 | expect(instance.getLineHeight(0)).toBe(20); |
| 467 | expect(instance.getVirtualizedHeight()).toBe(120036); |
| 468 | }); |
| 469 | |
| 470 | it("keeps width validity when a new editor attaches to the same file", async () => { |
| 471 | const { instance } = await makeFixture(); |
| 472 | instance.measure([[6000, 20]]); |
| 473 | const deliverResize = instance.observeLayout(); |
| 474 | vi.stubGlobal("SVGSVGElement", EditorElement); |
| 475 | vi.stubGlobal( |
| 476 | "document", |
| 477 | Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), |
| 478 | ); |
| 479 | const first = new Editor<undefined>(); |
| 480 | editors.push(first); |
| 481 | first.edit(instance); |
| 482 | first.cleanUp(); |
| 483 | const second = new Editor<undefined>(); |
| 484 | editors.push(second); |
| 485 | second.edit(instance); |
| 486 | instance.resizeContent(482.25); |
| 487 | deliverResize(); |
| 488 | drainRenderFrames(); |
| 489 | expect(instance.getLineHeight(0)).toBe(20); |
| 490 | expect(instance.getVirtualizedHeight()).toBe(120036); |
| 491 | }); |
| 492 | |
| 493 | it("ignores stale resize deliveries after cleanup", async () => { |
| 494 | const { instance } = await makeFixture(); |
| 495 | const deliverResize = instance.observeLayout(); |
| 496 | instance.dispose(); |
| 497 | clearRenderQueue(); |
| 498 | animationFrames.clear(); |
| 499 | deliverResize(); |
| 500 | expect(animationFrames.size).toBe(0); |
| 501 | }); |
| 502 | |
| 503 | it("does not discard a stable code width for gutter subpixel rounding", async () => { |
| 504 | const { instance } = await makeFixture("wrap", 6001, 482.25); |
| 505 | const deliverResize = instance.observeLayout(); |
| 506 | instance.resizeContent(482.234375, 539); |
| 507 | deliverResize(); |
| 508 | drainRenderFrames(); |
| 509 | expect(instance.getLineHeight(0)).toBe(80); |
| 510 | }); |
| 511 | |
| 512 | it("waits for a visible width instead of caching measurements while hidden", async () => { |
| 513 | const { instance } = await makeFixture(); |
| 514 | instance.measure([[6000, 20]]); |
| 515 | const deliverResize = instance.observeLayout(); |
| 516 | instance.resizeContent(0, 0); |
| 517 | deliverResize(); |
| 518 | drainRenderFrames(); |
| 519 | expect(instance.getLineHeight(0)).toBe(80); |
| 520 | instance.resizeContent(482.25); |
| 521 | deliverResize(); |
| 522 | drainRenderFrames(); |
| 523 | expect(instance.getLineHeight(0)).toBe(20); |
| 524 | expect(instance.getVirtualizedHeight()).toBe(120036); |
| 525 | }); |
| 526 | }); |
| 527 | |
| 528 | // Supply inert DOM transport so public Editor edits execute its real tokenizer |
| 529 | // and layout handoff. No native wrapping, observer delivery or scrolling is modeled. |
| 530 | class EditorElement extends MeasuredElement { |
| 531 | style: Record<string, string> = {}; |
| 532 | parentElement: EditorElement | null = null; |
| 533 | |
| 534 | appendChild(child: EditorElement) { |
| 535 | child.parentElement = this; |
| 536 | this.children.push(child); |
| 537 | return child; |
| 538 | } |
| 539 | |
| 540 | prepend(child: EditorElement) { |
| 541 | child.parentElement = this; |
| 542 | this.children.unshift(child); |
| 543 | } |
| 544 | |
| 545 | replaceChildren(...children: (EditorElement | string)[]) { |
| 546 | this.children = []; |
| 547 | for (const child of children) if (typeof child !== "string") this.appendChild(child); |
| 548 | } |
| 549 | |
| 550 | setAttribute() {} |
| 551 | removeAttribute() {} |
| 552 | addEventListener() {} |
| 553 | removeEventListener() {} |
| 554 | after() {} |
| 555 | |
| 556 | remove() { |
| 557 | if (this.parentElement) { |
| 558 | this.parentElement.children = this.parentElement.children.filter((child) => child !== this); |
| 559 | } |
| 560 | } |
| 561 | |
| 562 | set innerHTML(value: string) { |
| 563 | expect(value.startsWith("<svg")).toBe(true); |
| 564 | this.replaceChildren(new EditorElement()); |
| 565 | } |
| 566 | |
| 567 | get firstElementChild() { |
| 568 | return this.children[0] ?? null; |
| 569 | } |
| 570 | |
| 571 | querySelectorAll(selector: string) { |
| 572 | expect(selector).toBe("[data-code]"); |
| 573 | return this.children.filter((child) => "code" in child.dataset); |
| 574 | } |
| 575 | |
| 576 | querySelector(selector: string) { |
| 577 | expect(selector).toBe("[data-deletions]"); |
| 578 | return null; |
| 579 | } |
| 580 | |
| 581 | getContext() { |
| 582 | return { measureText: (text: string) => ({ width: text.length * 8 }) }; |
| 583 | } |
| 584 | } |
| 585 | |
| 586 | async function makeEditorFixture(lineCount: number) { |
| 587 | const { instance, file } = await makeFixture("wrap", lineCount); |
| 588 | vi.stubGlobal("SVGSVGElement", EditorElement); |
| 589 | vi.stubGlobal("Document", EditorElement); |
| 590 | vi.stubGlobal( |
| 591 | "document", |
| 592 | Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), |
| 593 | ); |
| 594 | vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); |
| 595 | vi.stubGlobal("requestAnimationFrame", () => 1); |
| 596 | vi.stubGlobal("cancelAnimationFrame", () => {}); |
| 597 | vi.stubGlobal("getComputedStyle", () => ({ |
| 598 | paddingTop: "0px", |
| 599 | fontSize: "13px", |
| 600 | fontFamily: "monospace", |
| 601 | tabSize: "2", |
| 602 | lineHeight: "20px", |
| 603 | })); |
| 604 | vi.stubGlobal( |
| 605 | "ResizeObserver", |
| 606 | class { |
| 607 | observe() {} |
| 608 | disconnect() {} |
| 609 | }, |
| 610 | ); |
| 611 | instance.setOptions({ |
| 612 | ...instance.options, |
| 613 | useTokenTransformer: true, |
| 614 | controlledSelection: true, |
| 615 | themeType: "dark", |
| 616 | }); |
| 617 | const content = new EditorElement(); |
| 618 | content.dataset.content = ""; |
| 619 | const gutter = new EditorElement(); |
| 620 | gutter.dataset.gutter = ""; |
| 621 | const code = new EditorElement(); |
| 622 | code.dataset.code = ""; |
| 623 | code.appendChild(gutter); |
| 624 | code.appendChild(content); |
| 625 | const shadow = new EditorElement(); |
| 626 | shadow.appendChild(code); |
| 627 | const host = Object.assign(new EditorElement(), { shadowRoot: shadow }); |
| 628 | const highlighter = await getSharedHighlighter({ |
| 629 | themes: ["pierre-dark"], |
| 630 | langs: ["text"], |
| 631 | preferredHighlighter: "shiki-wasm", |
| 632 | }); |
| 633 | const editor = new Editor<undefined>(); |
| 634 | editors.push(editor); |
| 635 | editor.edit(instance); |
| 636 | editor.__syncRenderView(highlighter, measuredElement(host), file, undefined, { |
| 637 | startingLine: 0, |
| 638 | totalLines: 1, |
| 639 | bufferBefore: 0, |
| 640 | bufferAfter: 0, |
| 641 | }); |
| 642 | const append = (count: number) => { |
| 643 | const lines = editor.getText().split("\n"); |
| 644 | const end = { line: lines.length - 1, character: lines.at(-1)!.length }; |
| 645 | editor.applyEdits([{ range: { start: end, end }, newText: "\n".repeat(count) }]); |
| 646 | }; |
| 647 | const remove = (count: number) => { |
| 648 | const lines = editor.getText().split("\n"); |
| 649 | const startLine = lines.length - count - 1; |
| 650 | editor.applyEdits([ |
| 651 | { |
| 652 | range: { |
| 653 | start: { line: startLine, character: lines[startLine]!.length }, |
| 654 | end: { line: lines.length - 1, character: lines.at(-1)!.length }, |
| 655 | }, |
| 656 | newText: "", |
| 657 | }, |
| 658 | ]); |
| 659 | }; |
| 660 | return { instance, editor, append, remove }; |
| 661 | } |
| 662 | |
| 663 | describe("editor gutter-width changes", () => { |
| 664 | it.each([ |
| 665 | [9999, 1], |
| 666 | [9998, 3], |
| 667 | ])( |
| 668 | "clears prefix measurements when %i lines grow by %i across a digit boundary", |
| 669 | async (lines, count) => { |
| 670 | const { instance, editor, append } = await makeEditorFixture(lines); |
| 671 | expect(instance.getLineHeight(0)).toBe(80); |
| 672 | append(count); |
| 673 | expect(editor.getText().split("\n")).toHaveLength(lines + count); |
| 674 | expect(instance.getLineHeight(0)).toBe(20); |
| 675 | expect(instance.getLineHeight(5000)).toBe(20); |
| 676 | }, |
| 677 | ); |
| 678 | |
| 679 | it.each([ |
| 680 | [10000, 1], |
| 681 | [10002, 4], |
| 682 | ])( |
| 683 | "clears prefix measurements when %i lines shrink by %i across a digit boundary", |
| 684 | async (lines, count) => { |
| 685 | const { instance, editor, remove } = await makeEditorFixture(lines); |
| 686 | remove(count); |
| 687 | expect(editor.getText().split("\n")).toHaveLength(lines - count); |
| 688 | expect(instance.getLineHeight(0)).toBe(20); |
| 689 | expect(instance.getLineHeight(5000)).toBe(20); |
| 690 | }, |
| 691 | ); |
| 692 | |
| 693 | it("clears newly measured prefixes on undo and redo across a digit boundary", async () => { |
| 694 | const { instance, editor, append } = await makeEditorFixture(9999); |
| 695 | append(1); |
| 696 | instance.measure([[0, 100]]); |
| 697 | editor.undo(); |
| 698 | expect(editor.getText().split("\n")).toHaveLength(9999); |
| 699 | expect(instance.getLineHeight(0)).toBe(20); |
| 700 | instance.measure([[0, 80]]); |
| 701 | editor.redo(); |
| 702 | expect(editor.getText().split("\n")).toHaveLength(10000); |
| 703 | expect(instance.getLineHeight(0)).toBe(20); |
| 704 | }); |
| 705 | |
| 706 | it.each([ |
| 707 | [9998, 1], |
| 708 | [10000, 2], |
| 709 | ])( |
| 710 | "retains prefix measurements when %i lines grow by %i without changing digit width", |
| 711 | async (lines, count) => { |
| 712 | const { instance, editor, append } = await makeEditorFixture(lines); |
| 713 | append(count); |
| 714 | expect(editor.getText().split("\n")).toHaveLength(lines + count); |
| 715 | expect(instance.getLineHeight(0)).toBe(80); |
| 716 | expect(instance.getLineHeight(5000)).toBe(80); |
| 717 | editor.undo(); |
| 718 | expect(instance.getLineHeight(0)).toBe(80); |
| 719 | editor.redo(); |
| 720 | expect(instance.getLineHeight(0)).toBe(80); |
| 721 | }, |
| 722 | ); |
| 723 | }); |