Skip to content

Commit ce15313

Browse files
Add computeMultilineTextLayout to support dynamic row height for custom cell renderers
1 parent 0875d78 commit ce15313

File tree

4 files changed

+200
-7
lines changed

4 files changed

+200
-7
lines changed

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export {
4040
roundedPoly,
4141
roundedRect,
4242
drawTextCellExternal as drawTextCell,
43+
computeMultilineTextLayoutExternal as computeMultilineTextLayout,
4344
} from "./internal/data-grid/render/data-grid-lib.js";
4445
export { CellSet } from "./internal/data-grid/cell-set.js";
4546
export { getDataEditorTheme as getDefaultTheme, useTheme } from "./common/styles.js";

packages/core/src/internal/data-grid/data-grid-types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,3 +722,14 @@ export class CompactSelection {
722722
}
723723
}
724724
}
725+
726+
/** @category Types */
727+
export interface MultilineTextLayout {
728+
split: string[]; // array of wrapped text lines (from canvas-hypertxt split)
729+
emHeight: number; // em height of the font in pixels
730+
lineHeight: number; // computed line height (theme.lineHeight * emHeight)
731+
actualHeight: number; // total text height: emHeight + lineHeight * (lines - 1)
732+
desiredHeight: number; // actualHeight + theme.cellVerticalPadding
733+
mustClip: boolean; // whether desiredHeight exceeds the cell height
734+
optimalY: number; // vertically centered Y position for the text block
735+
}

packages/core/src/internal/data-grid/render/data-grid-lib.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type InnerGridColumn,
88
type Rectangle,
99
type BaseGridCell,
10+
type MultilineTextLayout,
1011
} from "../data-grid-types.js";
1112
import { direction } from "../../../common/utils.js";
1213
import React from "react";
@@ -506,26 +507,65 @@ function truncateString(data: string, w: number): string {
506507
return data;
507508
}
508509

509-
function drawMultiLineText(
510+
function computeMultilineTextLayout(
510511
ctx: CanvasRenderingContext2D,
511512
data: string,
512-
x: number,
513513
y: number,
514514
w: number,
515515
h: number,
516-
bias: number,
517516
theme: FullTheme,
518-
contentAlign?: BaseGridCell["contentAlign"],
519517
hyperWrapping?: boolean
520-
) {
518+
): MultilineTextLayout {
521519
const fontStyle = theme.baseFontFull;
522520
const split = splitText(ctx, data, fontStyle, w - theme.cellHorizontalPadding * 2, hyperWrapping ?? false);
523521

524522
const emHeight = getEmHeight(ctx, fontStyle);
525523
const lineHeight = theme.lineHeight * emHeight;
526524

527525
const actualHeight = emHeight + lineHeight * (split.length - 1);
528-
const mustClip = actualHeight + theme.cellVerticalPadding > h;
526+
const desiredHeight = actualHeight + theme.cellVerticalPadding;
527+
528+
const mustClip = desiredHeight > h;
529+
const optimalY = y + h / 2 - actualHeight / 2;
530+
531+
return {
532+
split,
533+
emHeight,
534+
lineHeight,
535+
actualHeight,
536+
desiredHeight,
537+
mustClip,
538+
optimalY,
539+
};
540+
}
541+
542+
export function computeMultilineTextLayoutExternal(args: BaseDrawArgs, data: string, hyperWrapping?: boolean) {
543+
const { ctx, rect, theme } = args;
544+
const { y, width, height } = rect;
545+
return computeMultilineTextLayout(ctx, data, y, width, height, theme, hyperWrapping);
546+
}
547+
548+
function drawMultiLineText(
549+
ctx: CanvasRenderingContext2D,
550+
data: string,
551+
x: number,
552+
y: number,
553+
w: number,
554+
h: number,
555+
bias: number,
556+
theme: FullTheme,
557+
contentAlign?: BaseGridCell["contentAlign"],
558+
hyperWrapping?: boolean
559+
) {
560+
const { split, emHeight, lineHeight, mustClip, optimalY } = computeMultilineTextLayout(
561+
ctx,
562+
data,
563+
y,
564+
w,
565+
h,
566+
theme,
567+
hyperWrapping
568+
);
529569

530570
if (mustClip) {
531571
// well now we have to clip because we might render outside the cell vertically
@@ -534,7 +574,6 @@ function drawMultiLineText(
534574
ctx.clip();
535575
}
536576

537-
const optimalY = y + h / 2 - actualHeight / 2;
538577
let drawY = Math.max(y + theme.cellVerticalPadding, optimalY);
539578
for (const line of split) {
540579
drawSingleTextLine(ctx, line, x, drawY, w, emHeight, bias, theme, contentAlign);

packages/core/test/data-grid-lib.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
remapForDnDState,
66
type MappedGridColumn,
77
drawLastUpdateUnderlay,
8+
computeMultilineTextLayoutExternal,
89
} from "../src/internal/data-grid/render/data-grid-lib.js";
910
import { GridCellKind, type Rectangle } from "../src/internal/data-grid/data-grid-types.js";
1011
import { vi, type Mocked, expect, describe, test, it, beforeEach } from "vitest";
@@ -455,3 +456,144 @@ describe("drawWithLastUpdate", () => {
455456
expect(mockLastPrep.fillStyle).toBe(mockTheme.bgSearchResult);
456457
});
457458
});
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

Comments
 (0)