|
5 | 5 | remapForDnDState, |
6 | 6 | type MappedGridColumn, |
7 | 7 | drawLastUpdateUnderlay, |
| 8 | + computeMultilineTextLayoutExternal, |
8 | 9 | } from "../src/internal/data-grid/render/data-grid-lib.js"; |
9 | 10 | import { GridCellKind, type Rectangle } from "../src/internal/data-grid/data-grid-types.js"; |
10 | 11 | import { vi, type Mocked, expect, describe, test, it, beforeEach } from "vitest"; |
@@ -455,3 +456,144 @@ describe("drawWithLastUpdate", () => { |
455 | 456 | expect(mockLastPrep.fillStyle).toBe(mockTheme.bgSearchResult); |
456 | 457 | }); |
457 | 458 | }); |
| 459 | + |
| 460 | +describe("computeMultilineTextLayout", () => { |
| 461 | + vi.mock("canvas-hypertxt", () => ({ |
| 462 | + split: (_ctx: unknown, text: string, _font: string, _w: number, _hyperWrapping: boolean) => { |
| 463 | + return text.split("\n"); |
| 464 | + }, |
| 465 | + clearCache: vi.fn(), |
| 466 | + })); |
| 467 | + |
| 468 | + let mockCtx: Mocked<CanvasRenderingContext2D>; |
| 469 | + let theme: FullTheme; |
| 470 | + let defaultArgs: BaseDrawArgs; |
| 471 | + |
| 472 | + beforeEach(() => { |
| 473 | + mockCtx = { |
| 474 | + font: "", |
| 475 | + measureText: vi.fn().mockReturnValue({ |
| 476 | + actualBoundingBoxAscent: 10, |
| 477 | + actualBoundingBoxDescent: 2, |
| 478 | + width: 50, |
| 479 | + }), |
| 480 | + } as any; |
| 481 | + |
| 482 | + theme = mergeAndRealizeTheme(getDataEditorTheme()); |
| 483 | + defaultArgs = { |
| 484 | + ctx: mockCtx, |
| 485 | + theme, |
| 486 | + cellFillColor: theme.bgCell, |
| 487 | + rect: { x: 0, y: 0, width: 200, height: 100 }, |
| 488 | + cell: { kind: GridCellKind.Text, allowOverlay: false, data: "", displayData: "" }, |
| 489 | + col: 0, |
| 490 | + row: 0, |
| 491 | + highlighted: false, |
| 492 | + hoverAmount: 0, |
| 493 | + hoverX: undefined, |
| 494 | + hoverY: undefined, |
| 495 | + hyperWrapping: false, |
| 496 | + imageLoader: {} as any, |
| 497 | + spriteManager: {} as any, |
| 498 | + }; |
| 499 | + }); |
| 500 | + |
| 501 | + it("should compute height dimensions from emHeight and theme", () => { |
| 502 | + const result = computeMultilineTextLayoutExternal(defaultArgs, "Test"); |
| 503 | + |
| 504 | + expect(result.emHeight).toBe(12); |
| 505 | + expect(result.lineHeight).toBeCloseTo(theme.lineHeight * result.emHeight); |
| 506 | + expect(result.desiredHeight).toBeCloseTo(result.actualHeight + theme.cellVerticalPadding); |
| 507 | + }); |
| 508 | + |
| 509 | + it("should split text with explicit newlines", () => { |
| 510 | + const result = computeMultilineTextLayoutExternal( |
| 511 | + { |
| 512 | + ...defaultArgs, |
| 513 | + rect: { x: 0, y: 0, width: 500, height: 500 }, |
| 514 | + }, |
| 515 | + "Line 1\nLine 2\nLine 3" |
| 516 | + ); |
| 517 | + |
| 518 | + expect(result.split.length).toBe(3); |
| 519 | + expect(result.actualHeight).toBeCloseTo(result.emHeight + result.lineHeight * (result.split.length - 1)); |
| 520 | + }); |
| 521 | + |
| 522 | + it("should set mustClip to true when content overflows cell height", () => { |
| 523 | + const result = computeMultilineTextLayoutExternal( |
| 524 | + { |
| 525 | + ...defaultArgs, |
| 526 | + rect: { x: 0, y: 0, width: 20, height: 5 }, |
| 527 | + }, |
| 528 | + "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10" |
| 529 | + ); |
| 530 | + |
| 531 | + expect(result.mustClip).toBe(true); |
| 532 | + }); |
| 533 | + |
| 534 | + it("should not clip when content fits within cell height", () => { |
| 535 | + const result = computeMultilineTextLayoutExternal( |
| 536 | + { |
| 537 | + ...defaultArgs, |
| 538 | + rect: { x: 0, y: 0, width: 500, height: 500 }, |
| 539 | + }, |
| 540 | + "Short" |
| 541 | + ); |
| 542 | + |
| 543 | + expect(result.split.length).toBe(1); |
| 544 | + expect(result.mustClip).toBe(false); |
| 545 | + }); |
| 546 | + |
| 547 | + it("should handle lines that wrap at cell width and extend beyond a single line", async () => { |
| 548 | + const canvasHypertxt = await import("canvas-hypertxt"); |
| 549 | + const splitSpy = vi |
| 550 | + .spyOn(canvasHypertxt, "split") |
| 551 | + .mockImplementation((_ctx: unknown, text: string, _font: string, w: number, _hyperWrapping: boolean) => { |
| 552 | + // Simulate wrapping: each explicit line that exceeds the available width |
| 553 | + // gets broken into multiple wrapped lines |
| 554 | + const explicitLines = text.split("\n"); |
| 555 | + const result: string[] = []; |
| 556 | + for (const line of explicitLines) { |
| 557 | + // Approximate: assume ~10px per char, wrap when line exceeds width |
| 558 | + const charsPerLine = Math.max(1, Math.floor(w / 10)); |
| 559 | + for (let i = 0; i < line.length; i += charsPerLine) { |
| 560 | + result.push(line.slice(i, i + charsPerLine)); |
| 561 | + } |
| 562 | + } |
| 563 | + return result; |
| 564 | + }); |
| 565 | + |
| 566 | + const narrowWidth = 60; // allows ~6 chars per line |
| 567 | + const result = computeMultilineTextLayoutExternal( |
| 568 | + { |
| 569 | + ...defaultArgs, |
| 570 | + rect: { x: 0, y: 0, width: narrowWidth, height: 5000 }, |
| 571 | + }, |
| 572 | + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum" |
| 573 | + ); |
| 574 | + |
| 575 | + // Each line is a long lorem ipsum sentence (~100+ chars) at ~6 chars/line, wrapping heavily |
| 576 | + // 4 explicit newline-separated lines, each wrapping to many lines |
| 577 | + // Total split lines should be more than the 4 explicit newline-separated lines |
| 578 | + expect(result.split.length).toBeGreaterThan(4); |
| 579 | + expect(result.actualHeight).toBeCloseTo(result.emHeight + result.lineHeight * (result.split.length - 1)); |
| 580 | + expect(result.desiredHeight).toBeCloseTo(result.actualHeight + theme.cellVerticalPadding); |
| 581 | + // With many wrapped lines in a tall cell, content should fit |
| 582 | + expect(result.mustClip).toBe(false); |
| 583 | + |
| 584 | + splitSpy.mockRestore(); |
| 585 | + }); |
| 586 | + |
| 587 | + it("should center optimalY vertically within the cell", () => { |
| 588 | + const rect = { x: 0, y: 50, width: 500, height: 200 }; |
| 589 | + const result = computeMultilineTextLayoutExternal( |
| 590 | + { |
| 591 | + ...defaultArgs, |
| 592 | + rect, |
| 593 | + }, |
| 594 | + "Centered" |
| 595 | + ); |
| 596 | + |
| 597 | + expect(result.optimalY).toBeCloseTo(rect.y + rect.height / 2 - result.actualHeight / 2); |
| 598 | + }); |
| 599 | +}); |
0 commit comments