Files
kandev/apps/web/components/shared/use-markdown-table-resize.ts
T

263 lines
7.9 KiB
TypeScript

"use client";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type Dispatch,
type KeyboardEvent,
type PointerEvent,
type RefObject,
type SetStateAction,
} from "react";
import { resizeAdjacentColumns } from "@/lib/markdown/table-resize";
type TableGeometry = {
boundaries: number[];
columnWidths: number[];
height: number;
tableWidth: number;
top: number;
};
type DragState = {
boundaryIndex: number;
pointerId: number;
restoreTableWidth: number | null;
restoreWidths: number[] | null;
startWidths: number[];
startX: number;
tableWidth: number;
};
function readGeometry(table: HTMLTableElement, wrapper: HTMLDivElement): TableGeometry | null {
const cells = Array.from(table.rows.item(0)?.cells ?? []);
const tableRect = table.getBoundingClientRect();
const wrapperRect = wrapper.getBoundingClientRect();
if (cells.length < 2 || tableRect.width <= 0 || tableRect.height <= 0) return null;
const cellRects = cells.map((cell) => cell.getBoundingClientRect());
return {
boundaries: cellRects
.slice(0, -1)
.map((rect) => rect.right - wrapperRect.left + wrapper.scrollLeft),
columnWidths: cellRects.map((rect) => rect.width),
height: tableRect.height,
tableWidth: tableRect.width,
top: tableRect.top - wrapperRect.top + wrapper.scrollTop,
};
}
function setDocumentResizeState(active: boolean) {
document.body.style.cursor = active ? "col-resize" : "";
document.body.style.userSelect = active ? "none" : "";
}
type TableRefs = {
tableRef: RefObject<HTMLTableElement | null>;
wrapperRef: RefObject<HTMLDivElement | null>;
};
function useTableGeometry(
enabled: boolean,
refs: TableRefs,
widthsRef: RefObject<number[] | null>,
reset: () => void,
columnWidths: number[] | null,
) {
const [geometry, setGeometry] = useState<TableGeometry | null>(null);
const measure = useCallback(() => {
const table = refs.tableRef.current;
const wrapper = refs.wrapperRef.current;
if (!enabled || !table || !wrapper) {
if (!enabled && widthsRef.current) reset();
setGeometry(null);
return;
}
const columnCount = table.rows.item(0)?.cells.length ?? 0;
if (widthsRef.current && widthsRef.current.length !== columnCount) reset();
const next = readGeometry(table, wrapper);
setGeometry(next);
}, [enabled, refs, reset, widthsRef]);
useLayoutEffect(() => measure(), [columnWidths, measure]);
useLayoutEffect(() => {
const table = refs.tableRef.current;
const wrapper = refs.wrapperRef.current;
if (!enabled || !table || !wrapper) return;
const resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(table);
resizeObserver.observe(wrapper);
const mutationObserver = new MutationObserver(measure);
mutationObserver.observe(table, { characterData: true, childList: true, subtree: true });
window.addEventListener("resize", measure);
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
window.removeEventListener("resize", measure);
};
}, [enabled, measure, refs]);
return geometry;
}
type PointerResizeOptions = {
enabled: boolean;
fixedTableWidth: number | null;
refs: TableRefs;
setColumnWidths: (widths: number[] | null) => void;
setFixedTableWidth: Dispatch<SetStateAction<number | null>>;
widthsRef: RefObject<number[] | null>;
};
function usePointerResize({
enabled,
fixedTableWidth,
refs,
setColumnWidths,
setFixedTableWidth,
widthsRef,
}: PointerResizeOptions) {
const dragRef = useRef<DragState | null>(null);
const [activeBoundary, setActiveBoundary] = useState<number | null>(null);
const finishDrag = useCallback(
(event: PointerEvent<HTMLButtonElement>, cancelled: boolean) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
if (cancelled) {
setColumnWidths(drag.restoreWidths);
setFixedTableWidth(drag.restoreTableWidth);
}
dragRef.current = null;
setActiveBoundary(null);
setDocumentResizeState(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
},
[setColumnWidths],
);
useEffect(
() => () => {
if (!dragRef.current) return;
dragRef.current = null;
setDocumentResizeState(false);
},
[],
);
useEffect(() => {
if (enabled || !dragRef.current) return;
dragRef.current = null;
setActiveBoundary(null);
setDocumentResizeState(false);
}, [enabled]);
const startResize = useCallback(
(boundaryIndex: number, event: PointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) return;
const table = refs.tableRef.current;
const wrapper = refs.wrapperRef.current;
if (!table || !wrapper) return;
const measured = readGeometry(table, wrapper);
if (!measured) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
boundaryIndex,
pointerId: event.pointerId,
restoreTableWidth: fixedTableWidth,
restoreWidths: widthsRef.current ? [...widthsRef.current] : null,
startWidths: measured.columnWidths,
startX: event.clientX,
tableWidth: measured.tableWidth,
};
setActiveBoundary(boundaryIndex);
setDocumentResizeState(true);
},
[fixedTableWidth, refs, widthsRef],
);
const moveResize = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.preventDefault();
const delta = event.clientX - drag.startX;
if (delta === 0) return;
setFixedTableWidth(drag.tableWidth);
setColumnWidths(resizeAdjacentColumns(drag.startWidths, drag.boundaryIndex, delta));
},
[setColumnWidths, setFixedTableWidth],
);
return { activeBoundary, finishDrag, moveResize, startResize };
}
export function useMarkdownTableResize(enabled: boolean) {
const wrapperRef = useRef<HTMLDivElement>(null);
const tableRef = useRef<HTMLTableElement>(null);
const refs = useMemo(() => ({ tableRef, wrapperRef }), []);
const widthsRef = useRef<number[] | null>(null);
const [columnWidths, setColumnWidthsState] = useState<number[] | null>(null);
const [fixedTableWidth, setFixedTableWidth] = useState<number | null>(null);
const setColumnWidths = useCallback((widths: number[] | null) => {
widthsRef.current = widths;
setColumnWidthsState(widths);
}, []);
const reset = useCallback(() => {
setColumnWidths(null);
setFixedTableWidth(null);
}, [setColumnWidths]);
const geometry = useTableGeometry(enabled, refs, widthsRef, reset, columnWidths);
const pointerResize = usePointerResize({
enabled,
fixedTableWidth,
refs,
setColumnWidths,
setFixedTableWidth,
widthsRef,
});
const resizeWithKeyboard = useCallback(
(boundaryIndex: number, event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "Enter") {
event.preventDefault();
reset();
return;
}
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
const table = refs.tableRef.current;
const wrapper = refs.wrapperRef.current;
const measured = table && wrapper ? readGeometry(table, wrapper) : null;
const widths = widthsRef.current ?? measured?.columnWidths;
if (!widths || !measured) return;
event.preventDefault();
setFixedTableWidth(fixedTableWidth ?? measured.tableWidth);
setColumnWidths(
resizeAdjacentColumns(widths, boundaryIndex, event.key === "ArrowRight" ? 8 : -8),
);
},
[fixedTableWidth, refs, reset, setColumnWidths],
);
return {
...pointerResize,
columnWidths,
fixedTableWidth,
geometry,
reset,
resizeWithKeyboard,
tableRef,
wrapperRef,
};
}