Skip to content

Commit

Permalink
Revised the sheet table component to explicitly measure text in colum…
Browse files Browse the repository at this point in the history
…ns before rendering.
  • Loading branch information
erikh2000 authored and erikh2000 committed Dec 29, 2024
1 parent 49e7c18 commit 3844250
Show file tree
Hide file tree
Showing 5 changed files with 62 additions and 82 deletions.
40 changes: 36 additions & 4 deletions src/components/sheetTable/DOMTextMeasurer.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
const MEASURABLE_SPACE = ' \u200B'; // Adding Unicode zero-width character to end will cause the browser to measure the preceding space.

type WordWidths = Record<string,number>;
type WordWidthsCache = Record<string,WordWidths>;

// Shared per-layout word widths across all instances of DOMTextMeasurer.
const theWordWidthsCache:WordWidthsCache = {};

// Use classname as key to cache word widths specific to that layout.
// Still possible to have collisions if two different layouts use the same classname. If you have that problem, you can create a separate className for each layout.
function _getOrCreateWordWidths(className:string):WordWidths {
if (theWordWidthsCache[className]) return theWordWidthsCache[className];
return theWordWidthsCache[className] = {};
}

class DOMTextMeasurer {
private _parentElement:HTMLElement;
private _className:string;
private _measureElement:HTMLElement|null;
private _isInitialized:boolean;
private _wordWidths:Record<string,number> = {};

// Measurement will be based on styles inherited from both parentElement and className.
constructor(parentElement:HTMLElement, className:string) {
this._parentElement = parentElement;
this._className = className;
Expand All @@ -20,17 +37,32 @@ class DOMTextMeasurer {
this._measureElement.style.visibility = 'hidden';
this._measureElement.style.pointerEvents = 'none';
this._parentElement.appendChild(this._measureElement);
this._wordWidths = _getOrCreateWordWidths(this._className);

this._isInitialized = true;
}

public measureTextWidth(text:string):number {
this._initializeAsNeeded();
if (!this._measureElement) throw 'Unexpected';
this._measureElement.textContent = text;
const width = this._measureElement.offsetWidth;
this._measureElement.textContent = ''; // Save memory.
return width;

let totalWidth = 0;
const words = text.split(' ');
for(let i = 0; i < words.length; i++) {
const word = words[i] + (i < words.length - 1 ? MEASURABLE_SPACE : '');
if (this._wordWidths[word]) {
totalWidth += this._wordWidths[word];
continue;
}

this._measureElement.textContent = word;
const width = this._measureElement.offsetWidth;
this._wordWidths[word] = width;
totalWidth += width;
}

this._measureElement.textContent = '';
return totalWidth;
}
}

Expand Down
14 changes: 2 additions & 12 deletions src/components/sheetTable/SheetHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,16 @@
import HoneColumn from '@/sheets/types/HoneColumn';
import styles from './SheetHeader.module.css';
import { useRef, useEffect } from "react";

type Props = {
columns:HoneColumn[],
columnWidths:number[],
onMeasureColumns:(columnWidths:number[])=>void
}

function SheetHeader({columns, columnWidths, onMeasureColumns}:Props) {
const spansRef = useRef<(HTMLSpanElement|null)[]>([]);

useEffect(() => {
if (columnWidths.length === columns.length) return; // No new columns to measure.
const measuredWidths = spansRef.current.map(span => span ? span.offsetWidth : 0);
onMeasureColumns(measuredWidths);
}, [columns, columnWidths, onMeasureColumns]);

function SheetHeader({columns, columnWidths}:Props) {
const cells = columns.map((column, columnI) => {
const style = columnWidths[columnI] ? {width:columnWidths[columnI]} : {};
return (
<span key={columnI} ref={el => spansRef.current[columnI] = el} style={style}>
<span key={columnI} style={style}>
{column.name}
</span>
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/sheetTable/SheetRow.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
border-color: lightgrey;
}

.sheetRow span {
.sheetCell, .sheetRow span {
display: inline-block;
white-space: nowrap;
margin-left: .5rem;
Expand Down
17 changes: 3 additions & 14 deletions src/components/sheetTable/SheetRow.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,17 @@
import Row from "@/sheets/types/Row"
import styles from './SheetRow.module.css';
import { useRef, useEffect } from "react";

type Props = {
row:Row,
rowNo:number,
columnWidths:number[],
onMeasureColumns:(columnWidths:number[])=>void
columnWidths:number[]
}

function SheetRow({row, rowNo, columnWidths, onMeasureColumns}:Props) {
const spansRef = useRef<(HTMLSpanElement|null)[]>([]);

useEffect(() => {
if (columnWidths.length === row.length) return; // No new columns to measure.
console.log('Measuring columns...');
const measuredWidths = spansRef.current.map(span => span ? span.offsetWidth : 0);
onMeasureColumns(measuredWidths);
}, [row, columnWidths, onMeasureColumns]);

function SheetRow({row, rowNo, columnWidths}:Props) {
const cells = row.map((cell, cellI) => {
const style = columnWidths[cellI] ? {width:columnWidths[cellI]} : {};
return (
<span key={cellI} ref={el => spansRef.current[cellI] = el} style={style}>
<span key={cellI} style={style}>
{cell}
</span>
);
Expand Down
71 changes: 20 additions & 51 deletions src/components/sheetTable/SheetTable.tsx
Original file line number Diff line number Diff line change
@@ -1,80 +1,49 @@
import { useState, useRef, useEffect, MutableRefObject, useLayoutEffect } from 'react';
import { useState, useRef, useEffect } from 'react';

import styles from './SheetTable.module.css';
import rowStyles from './SheetRow.module.css';
import SheetRow from "./SheetRow";
import SheetHeader from './SheetHeader';
import HoneSheet from '@/sheets/types/HoneSheet';
import SheetFooter from './SheetFooter';
import DOMTextMeasurer from './DOMTextMeasurer';

type Props = {
sheet:HoneSheet,
footerText?:string
}

type MeasurementState = {
isMeasuring:boolean,
widths:number[],
totalMeasureCount:number,
measuredRowNos:Set<number>
}

function _duplicateMeasurementState(from:MeasurementState):MeasurementState {
return {
isMeasuring:from.isMeasuring,
widths:[...from.widths],
totalMeasureCount:from.totalMeasureCount,
measuredRowNos:new Set(from.measuredRowNos)
};
}

function _updateColumnWidths(rowNo:number, widths:number[], measurementStateRef:MutableRefObject<MeasurementState>, setColumnWidths:Function) {
if (measurementStateRef.current.measuredRowNos.has(rowNo) || !measurementStateRef.current.isMeasuring) return;
const nextState = _duplicateMeasurementState(measurementStateRef.current);
nextState.measuredRowNos.add(rowNo);
for(let i=0; i<widths.length; i++) {
if (nextState.widths[i] === undefined || nextState.widths[i] < widths[i]) {
nextState.widths[i] = widths[i];
function _measureColumnWidths(sheetTableElement:HTMLDivElement, sheet:HoneSheet):number[] {
const measurer = new DOMTextMeasurer(sheetTableElement, rowStyles.sheetRowCell);
const widths = sheet.columns.map(column => measurer.measureTextWidth(column.name));
for(let rowI = 0; rowI < sheet.rows.length; rowI++) {
const row = sheet.rows[rowI];
for(let cellI = 0; cellI < row.length; cellI++) {
const cell = '' + row[cellI];
widths[cellI] = Math.max(widths[cellI], measurer.measureTextWidth(cell));
}
}
if (nextState.totalMeasureCount - nextState.measuredRowNos.size === 0) {
console.log('Finished measuring columns. Widths=', nextState.widths);
nextState.isMeasuring = false;
setColumnWidths(nextState.widths);
}
measurementStateRef.current = nextState;
}

function _createEmptyMeasurementState(rowCount:number):MeasurementState {
return {
isMeasuring:true,
widths:[],
totalMeasureCount:rowCount+1, //+1 for header.
measuredRowNos:new Set()
};
return widths;
}

function SheetTable({sheet, footerText}:Props) {
const measurementStateRef = useRef<MeasurementState>(_createEmptyMeasurementState(sheet.rows.length));
const sheetTableElement = useRef<HTMLDivElement>(null);
const [columnWidths, setColumnWidths] = useState<number[]>([]);

useEffect(() => {
if (measurementStateRef.current.isMeasuring) return;
console.log('Measuring columns...');
measurementStateRef.current = _createEmptyMeasurementState(sheet.rows.length);
setColumnWidths([]);
if (!sheetTableElement.current) return;
const nextColumnWidths:number[] = _measureColumnWidths(sheetTableElement.current, sheet);
setColumnWidths(nextColumnWidths);
}, [sheet, sheet.rows]);

console.log('SheetTable rendering with columnWidths=', columnWidths);
const rowsContent = sheet.rows.map((row, rowI) =>
<SheetRow key={rowI} row={row} rowNo={rowI+1} columnWidths={columnWidths}
onMeasureColumns={measuredWidths => _updateColumnWidths(rowI+1, measuredWidths, measurementStateRef, setColumnWidths)}/>
const rowsContent = columnWidths.length === 0 ? null : sheet.rows.map((row, rowI) =>
<SheetRow key={rowI} row={row} rowNo={rowI+1} columnWidths={columnWidths} />
);

return (
<div className={styles.scrollContainer}>
<div className={styles.sheetTable}>
<SheetHeader columns={sheet.columns} columnWidths={columnWidths}
onMeasureColumns={measuredWidths => _updateColumnWidths(0, measuredWidths, measurementStateRef, setColumnWidths)}/>
<div className={styles.sheetTable} ref={sheetTableElement}>
<SheetHeader columns={sheet.columns} columnWidths={columnWidths} />
{rowsContent}
<SheetFooter text={footerText ?? ''}/>
</div>
Expand Down

0 comments on commit 3844250

Please sign in to comment.