139 lines
6.1 KiB
TypeScript
139 lines
6.1 KiB
TypeScript
import { useId, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
|
|
import { useResultTableSelection } from './ResultTableSelection';
|
|
|
|
const MIN_COLUMN_WIDTH = 72;
|
|
const MAX_COLUMN_WIDTH = 720;
|
|
|
|
interface ColumnDrag {
|
|
pointerId: number;
|
|
columnIndex: number;
|
|
startX: number;
|
|
startWidths: number[];
|
|
}
|
|
|
|
interface Props {
|
|
headers: ReactNode[];
|
|
children?: ReactNode;
|
|
enabled?: boolean;
|
|
className?: string;
|
|
sourceSheetId?: string;
|
|
}
|
|
|
|
function clampColumnWidth(width: number): number {
|
|
return Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, Math.round(width)));
|
|
}
|
|
|
|
function columnWidthStyle(width: number): CSSProperties {
|
|
return { width: `${width}px` };
|
|
}
|
|
|
|
function measuredColumnWidths(table: HTMLTableElement, columnCount: number): number[] {
|
|
const headers = Array.from(table.tHead?.rows[0]?.cells ?? []);
|
|
return Array.from({ length: columnCount }, (_, index) => clampColumnWidth(headers[index]?.getBoundingClientRect().width || MIN_COLUMN_WIDTH));
|
|
}
|
|
|
|
/**
|
|
* Misst den ungebrochenen Inhalt der Zielspalte. `white-space: nowrap` wird nur
|
|
* fuer den Messaugenblick gesetzt und sofort wieder entfernt; importierter
|
|
* Inhalt wird dabei weder kopiert noch protokolliert.
|
|
*/
|
|
function fittedColumnWidth(table: HTMLTableElement, columnIndex: number): number {
|
|
const cells = Array.from(table.rows)
|
|
.map(row => row.cells[columnIndex])
|
|
.filter((cell): cell is HTMLTableCellElement => Boolean(cell));
|
|
let preferred = MIN_COLUMN_WIDTH;
|
|
for (const cell of cells) {
|
|
const previousWhiteSpace = cell.style.whiteSpace;
|
|
const previousWidth = cell.style.width;
|
|
cell.style.whiteSpace = 'nowrap';
|
|
cell.style.width = 'auto';
|
|
preferred = Math.max(preferred, cell.scrollWidth + 2);
|
|
cell.style.whiteSpace = previousWhiteSpace;
|
|
cell.style.width = previousWidth;
|
|
}
|
|
return clampColumnWidth(preferred);
|
|
}
|
|
|
|
export function ResizableResultTable({ headers, children, enabled = false, className = 'rich-text-table', sourceSheetId }: Props) {
|
|
const tableId = useId();
|
|
const selection = useResultTableSelection();
|
|
const columnCount = headers.length;
|
|
const tableRef = useRef<HTMLTableElement>(null);
|
|
const dragRef = useRef<ColumnDrag | null>(null);
|
|
const [columnWidths, setColumnWidths] = useState<number[]>([]);
|
|
const appliedWidths = enabled && columnWidths.length === columnCount ? columnWidths : [];
|
|
const tableStyle: CSSProperties | undefined = appliedWidths.length
|
|
? { tableLayout: 'fixed', width: `${appliedWidths.reduce((sum, width) => sum + width, 0)}px` }
|
|
: undefined;
|
|
|
|
const startResize = (columnIndex: number) => (event: ReactPointerEvent<HTMLSpanElement>) => {
|
|
if (!enabled || !tableRef.current) return;
|
|
const startWidths = measuredColumnWidths(tableRef.current, columnCount);
|
|
dragRef.current = { pointerId: event.pointerId, columnIndex, startX: event.clientX, startWidths };
|
|
setColumnWidths(startWidths);
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
};
|
|
|
|
const moveResize = (event: ReactPointerEvent<HTMLSpanElement>) => {
|
|
const drag = dragRef.current;
|
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
const next = [...drag.startWidths];
|
|
next[drag.columnIndex] = clampColumnWidth(drag.startWidths[drag.columnIndex] + event.clientX - drag.startX);
|
|
setColumnWidths(next);
|
|
};
|
|
|
|
const stopResize = (event: ReactPointerEvent<HTMLSpanElement>) => {
|
|
if (dragRef.current?.pointerId !== event.pointerId) return;
|
|
dragRef.current = null;
|
|
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture?.(event.pointerId);
|
|
};
|
|
|
|
const autoFit = (columnIndex: number) => {
|
|
const table = tableRef.current;
|
|
if (!enabled || !table) return;
|
|
const current = measuredColumnWidths(table, columnCount);
|
|
current[columnIndex] = fittedColumnWidth(table, columnIndex);
|
|
setColumnWidths(current);
|
|
};
|
|
|
|
const resizeByKeyboard = (columnIndex: number, delta: number) => {
|
|
const table = tableRef.current;
|
|
if (!enabled || !table) return;
|
|
const current = appliedWidths.length ? [...appliedWidths] : measuredColumnWidths(table, columnCount);
|
|
current[columnIndex] = clampColumnWidth(current[columnIndex] + delta);
|
|
setColumnWidths(current);
|
|
};
|
|
|
|
return (
|
|
<table ref={tableRef} className={`${className}${enabled ? ' is-column-resizable' : ''}${appliedWidths.length ? ' has-custom-column-widths' : ''}`} data-result-table-id={selection ? tableId : undefined} data-result-sheet-id={sourceSheetId} style={tableStyle}>
|
|
{(selection || enabled) && <caption className={selection ? 'result-table-selection' : 'result-column-instructions'}>
|
|
{selection && <label><input type="checkbox" checked={selection.selected.has(tableId)} onChange={() => selection.toggle(tableId)} /> Select table for Excel export</label>}
|
|
{enabled && <span className="result-column-instructions">Column widths can be changed by dragging a header separator or fitted by double-clicking it.</span>}
|
|
</caption>}
|
|
{appliedWidths.length > 0 && <colgroup>{appliedWidths.map((width, index) => <col key={index} style={columnWidthStyle(width)} />)}</colgroup>}
|
|
<thead><tr>{headers.map((header, index) => <th key={index}>{header}{enabled && index < columnCount - 1 && <span
|
|
className="result-column-resizer"
|
|
role="separator"
|
|
aria-label={`Resize column ${index + 1}`}
|
|
aria-orientation="vertical"
|
|
tabIndex={0}
|
|
onPointerDown={startResize(index)}
|
|
onPointerMove={moveResize}
|
|
onPointerUp={stopResize}
|
|
onPointerCancel={stopResize}
|
|
onDoubleClick={event => { event.preventDefault(); event.stopPropagation(); autoFit(index); }}
|
|
onKeyDown={event => {
|
|
if (event.key === 'Enter') { event.preventDefault(); autoFit(index); }
|
|
if (event.key === 'ArrowLeft') { event.preventDefault(); resizeByKeyboard(index, -16); }
|
|
if (event.key === 'ArrowRight') { event.preventDefault(); resizeByKeyboard(index, 16); }
|
|
}}
|
|
/>}</th>)}</tr></thead>
|
|
<tbody>{children}</tbody>
|
|
</table>
|
|
);
|
|
}
|
|
|
|
export { fittedColumnWidth, measuredColumnWidths };
|