import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' import CountsCellMenu from '@/components/counts/CountsCellMenu' import { countsClipboard, writeCountsClipboard } from '@/lib/countsClipboard' import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout' import { cn } from '@/lib/utils' import { useUITranslation } from '@/lib/useUITranslation' import type { ClipboardEvent, ReactNode } from 'react' import type { CountsClipboardTable } from '@/lib/countsClipboard' import type { CountsMenuAction } from '@/components/counts/CountsCellMenu' type Point = { row: number; column: number } type Selection = { identity: string; anchor: Point; end: Point } const interactive = 'a,button,input,select,textarea,summary,[contenteditable],[data-counts-resize]' const cellGrid = (table: HTMLTableElement): HTMLTableCellElement[][] => { const grid: HTMLTableCellElement[][] = [] for (const [row, tr] of Array.from (table.tBodies[0]?.rows ?? []).entries ()) { grid[row] ??= [] let column = 0 for (const cell of Array.from (tr.cells)) { while (grid[row][column] != null) ++column for (let y = 0; y < cell.rowSpan; ++y) { grid[row + y] ??= [] for (let x = 0; x < cell.colSpan; ++x) grid[row + y][column + x] = cell } column += cell.colSpan } } return grid } const cellAt = (table: HTMLTableElement, point: Point) => cellGrid (table)[point.row]?.[point.column] const CountsSelectableTable = ( { family, columns, identity, children, className, name, copyTable }: { family: string columns: string[] identity: string children: ReactNode className?: string name?: { text: string; language: string } copyTable?: () => CountsClipboardTable }, ) => { const id = useId () const root = useRef (null) const dragging = useRef (false) const touchArmed = useRef (false) const touchPress = useRef<{ x: number; y: number timer: ReturnType } | null> (null) const [menu, setMenu] = useState<{ x: number; y: number; point: Point identity: string } | null> (null) const suppressClick = useRef (false) const [stored, setStored] = useState (null) const selection = stored?.identity === identity ? stored : null const layout = useCountsTableLayout () const { label, message } = useUITranslation ('counts') const bounds = useMemo (() => selection == null ? null : { top: Math.min (selection.anchor.row, selection.end.row), bottom: Math.max (selection.anchor.row, selection.end.row), left: Math.min (selection.anchor.column, selection.end.column), right: Math.max (selection.anchor.column, selection.end.column) }, [selection]) const finish = () => { dragging.current = false touchArmed.current = false if (touchPress.current != null) clearTimeout (touchPress.current.timer) touchPress.current = null } useEffect (() => { const table = root.current const preventPan = (event: TouchEvent) => { if (touchArmed.current) event.preventDefault () } table?.addEventListener ('touchmove', preventPan, { passive: false }) return () => { table?.removeEventListener ('touchmove', preventPan) if (touchPress.current != null) clearTimeout (touchPress.current.timer) } }, []) useLayoutEffect (() => { if (root.current == null) return const grid = cellGrid (root.current) const assigned = new Set () for (const [row, cells] of grid.entries ()) { for (const [column, cell] of cells.entries ()) { if (assigned.has (cell)) continue assigned.add (cell) const selected = bounds != null && row <= bounds.bottom && row + cell.rowSpan - 1 >= bounds.top && column <= bounds.right && column + cell.colSpan - 1 >= bounds.left cell.id = `${id}-${row}-${column}` if (cell.getAttribute ('role') !== 'rowheader') cell.setAttribute ('role', 'gridcell') cell.setAttribute ('aria-selected', String (selected)) if (selected) cell.dataset.countsSelected = '' else delete cell.dataset.countsSelected } } if (bounds != null) root.current.setAttribute ('aria-activedescendant', grid[bounds.bottom]?.[bounds.right]?.id ?? '') else root.current.removeAttribute ('aria-activedescendant') }, [bounds, children, id]) const pointFor = (element: Element | null, y?: number): Point | null => { const table = root.current if (table == null || element == null || !table.contains (element)) return null let cell = element.closest ('td') while (cell != null && cell.closest ('table') !== table) cell = cell.closest ('table')?.parentElement?.closest ('td') ?? null if (cell == null || cell.parentElement?.parentElement !== table.tBodies[0]) return null const grid = cellGrid (table) const hitRow = y == null ? -1 : Array.from (table.tBodies[0].rows).findIndex (row => { const rect = row.getBoundingClientRect () return y >= rect.top && y < rect.bottom }) for (const [row, cells] of grid.entries ()) { if (hitRow >= 0 && row !== hitRow) continue const column = cells.indexOf (cell as HTMLTableCellElement) if (column >= 0) return { row, column } } return null } const closeMenu = useCallback (() => { setMenu (null) root.current?.focus ({ preventScroll: true }) }, []) const openMenu = (point: Point, x: number, y: number) => { const inside = bounds != null && point.row >= bounds.top && point.row <= bounds.bottom && point.column >= bounds.left && point.column <= bounds.right if (inside) setStored (selection) else setStored ({ identity, anchor: point, end: point }) setMenu ({ point, x, y, identity }) } const clipboard = (headers = false) => { if (bounds == null || root.current == null) return null if (copyTable != null) { const table = copyTable () return countsClipboard ({ columns: table.columns.slice (bounds.left, bounds.right + 1), headerRows: table.headerRows?.map (row => row.slice (bounds.left, bounds.right + 1)), rows: table.rows.slice (bounds.top, bounds.bottom + 1) .map (row => row.slice (bounds.left, bounds.right + 1)) }, headers) } const rows: string[][] = [] for (let row = bounds.top; row <= bounds.bottom; ++row) { const values: string[] = [] for (let column = bounds.left; column <= bounds.right; ++column) { const cell = cellAt (root.current, { row, column }) values.push (cell?.dataset.countsCopy ?? cell?.innerText ?? '') } rows.push (values) } if (headers) { const heading = root.current.tHead?.rows[0] rows.unshift (Array.from ({ length: bounds.right - bounds.left + 1 }, (entry, index) => { void entry const cell = heading?.cells[bounds.left + index] return cell?.dataset.countsHeading ?? cell?.textContent ?? '' })) } return countsClipboard (rows, headers) } const menuAction = (action: CountsMenuAction) => { if (menu == null || root.current == null) return if (action === 'copy' || action === 'copyHeaders') { const payload = clipboard (action === 'copyHeaders') if (payload != null) void writeCountsClipboard (payload).catch (error => { console.error (error) }) } else { const lastRow = (root.current.tBodies[0]?.rows.length ?? 1) - 1 const lastColumn = columns.length - 1 const anchor = { row: action === 'row' ? menu.point.row : 0, column: action === 'column' ? menu.point.column : 0 } const end = { row: action === 'row' ? menu.point.row : lastRow, column: action === 'column' ? menu.point.column : lastColumn } setStored ({ identity, anchor, end }) } closeMenu () } const copy = (event: ClipboardEvent) => { if (document.activeElement !== root.current) return const payload = clipboard () if (payload == null) return event.preventDefault () event.stopPropagation () event.clipboardData.setData ('text/plain', payload.text) try { event.clipboardData.setData ('text/html', payload.html) } catch { // Plain TSV remains available when the browser disallows HTML clipboard data. } } const totalWidth = columns.reduce ((sum, key) => sum + layout.width (family, key), 0) return ( tbody>tr>td[data-counts-selected]]:bg-blue-100', '[&>tbody>tr>td[data-counts-selected]]:text-slate-900', 'dark:[&>tbody>tr>td[data-counts-selected]]:bg-blue-900', 'dark:[&>tbody>tr>td[data-counts-selected]]:text-slate-100', '[&>tbody>tr>td[data-counts-selected]]:outline', '[&>tbody>tr>td[data-counts-selected]]:outline-blue-500', '[&>tbody>tr>td[data-counts-selected]]:outline-offset-[-1px]', className)} onCopy={copy} onClickCapture={event => { if ((event.target as Element).closest ('table') !== event.currentTarget || !suppressClick.current) return event.preventDefault () event.stopPropagation () suppressClick.current = false }} onPointerDown={event => { const element = event.target as Element if (element.closest ('table') !== event.currentTarget) return suppressClick.current = false if (element.closest (interactive) != null || event.button !== 0) return if (element instanceof HTMLElement && element.hasAttribute ('data-counts-scroll')) { const rect = element.getBoundingClientRect () if (event.clientX >= rect.left + element.clientLeft + element.clientWidth || event.clientY >= rect.top + element.clientTop + element.clientHeight) return } const point = pointFor (element, event.clientY) if (point == null) return event.stopPropagation () setMenu (null) event.currentTarget.focus ({ preventScroll: true }) setStored ({ identity, anchor: event.shiftKey && selection != null ? selection.anchor : point, end: point }) finish () if (event.pointerType === 'mouse') { event.preventDefault () dragging.current = true event.currentTarget.setPointerCapture (event.pointerId) } else { const table = event.currentTarget const pointerId = event.pointerId touchPress.current = { x: event.clientX, y: event.clientY, timer: setTimeout (() => { suppressClick.current = true openMenu (point, event.clientX, event.clientY) dragging.current = true touchArmed.current = true table.setPointerCapture (pointerId) }, 450) } } }} onPointerMove={event => { if (touchPress.current != null && !touchArmed.current && Math.hypot ( event.clientX - touchPress.current.x, event.clientY - touchPress.current.y) > 8) finish () if (!dragging.current || event.buttons === 0) return const point = pointFor (document.elementFromPoint (event.clientX, event.clientY), event.clientY) if (point == null) return if (touchArmed.current && touchPress.current != null && Math.hypot (event.clientX - touchPress.current.x, event.clientY - touchPress.current.y) <= 8) return setMenu (null) event.stopPropagation () setStored (previous => previous?.identity !== identity ? previous : { ...previous, end: point }) }} onPointerUp={finish} onPointerCancel={finish} onLostPointerCapture={event => { if (event.target === event.currentTarget) finish () }} onContextMenu={event => { const element = event.target as Element if (element.closest ('table') !== event.currentTarget) return const point = pointFor (element, event.clientY) if (point == null || element.closest ('[data-counts-resize]') != null) return event.preventDefault () event.stopPropagation () openMenu (point, event.clientX, event.clientY) }} onKeyDown={event => { if (event.target !== event.currentTarget) return if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) { const point = selection?.end ?? { row: 0, column: 0 } const rect = cellAt (event.currentTarget, point)?.getBoundingClientRect () if (rect != null) { event.preventDefault () openMenu (point, rect.left, rect.bottom) } return } if (event.key === 'Escape') { setStored (null) return } if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return const rows = event.currentTarget.tBodies[0]?.rows if (rows == null || rows.length === 0) return event.preventDefault () const previous = selection?.end ?? { row: 0, column: 0 } const key = selection == null ? '' : event.key const point = { row: Math.max (0, Math.min (rows.length - 1, previous.row + (key === 'ArrowDown' ? 1 : (key === 'ArrowUp' ? -1 : 0)))), column: Math.max (0, Math.min (columns.length - 1, previous.column + (key === 'ArrowRight' ? 1 : (key === 'ArrowLeft' ? -1 : 0)))) } setStored ({ identity, anchor: event.shiftKey && selection != null ? selection.anchor : point, end: point }) cellAt (event.currentTarget, point)?.scrollIntoView ({ block: 'nearest', inline: 'nearest' }) }}> {columns.map (key => ( ))} {children}
{label ('selection.hint')}
{menu?.identity === identity && }
) } export default CountsSelectableTable