355 行
14 KiB
TypeScript
355 行
14 KiB
TypeScript
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<HTMLTableElement> (null)
|
|
const dragging = useRef (false)
|
|
const touchArmed = useRef (false)
|
|
const touchPress = useRef<{ x: number; y: number
|
|
timer: ReturnType<typeof setTimeout> } | null> (null)
|
|
const [menu, setMenu] = useState<{ x: number; y: number; point: Point
|
|
identity: string } | null> (null)
|
|
const suppressClick = useRef (false)
|
|
const [stored, setStored] = useState<Selection | null> (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<HTMLTableCellElement> ()
|
|
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<HTMLTableElement>) => {
|
|
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 (
|
|
<CountsTableScope.Provider value={family}>
|
|
<table ref={root} role="grid" tabIndex={0} aria-multiselectable="true"
|
|
aria-label={name?.text ?? message ('selection.table').text}
|
|
aria-describedby={`${id}-hint`}
|
|
|
|
style={{ tableLayout: 'fixed', width: totalWidth }}
|
|
className={cn ('counts-grid select-none border-separate border-spacing-0 outline-none',
|
|
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500',
|
|
'dark:focus-visible:outline-blue-400',
|
|
'[&>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' })
|
|
}}>
|
|
<caption id={`${id}-hint`} className="sr-only">{label ('selection.hint')}</caption>
|
|
<colgroup>{columns.map (key => (
|
|
<col key={key} style={{ width: layout.width (family, key) }}/>))}</colgroup>
|
|
{children}
|
|
</table>
|
|
{menu?.identity === identity && <CountsCellMenu x={menu.x} y={menu.y}
|
|
act={menuAction} close={closeMenu}/>}
|
|
</CountsTableScope.Provider>)
|
|
}
|
|
|
|
|
|
export default CountsSelectableTable
|