このコミットが含まれているのは:
2026-10-25 16:47:05 +09:00
コミット e71663bd22
16個のファイルの変更、523行の追加、89行の削除
+100 -22
ファイルの表示
@@ -1,20 +1,20 @@
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
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 { 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 escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!)
const cellAt = (table: HTMLTableElement, point: Point) =>
table.tBodies[0]?.rows[point.row]?.cells[point.column]
@@ -34,6 +34,9 @@ const CountsSelectableTable = (
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 ()
@@ -87,10 +90,19 @@ const CountsSelectableTable = (
return { row: (cell.parentElement as HTMLTableRowElement).sectionRowIndex,
column: (cell as HTMLTableCellElement).cellIndex }
}
const copy = (event: ClipboardEvent<HTMLTableElement>) => {
if (bounds == null || root.current == null || document.activeElement !== root.current) return
event.preventDefault ()
event.stopPropagation ()
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
const rows: string[][] = []
for (let row = bounds.top; row <= bounds.bottom; ++row)
{
@@ -102,18 +114,50 @@ const CountsSelectableTable = (
}
rows.push (values)
}
const text = rows.map (row => row.map (value => {
const safe = /^\s*[=+@-]/.test (value) ? `'${value}` : value
return `"${safe.replaceAll ('"', '""')}"`
}).join ('\t')).join ('\r\n')
const html = '<table>' + rows.map (row => '<tr>' + row.map (value =>
`<td style="white-space:pre-wrap">${escapeHTML (
/^\s*[=+@-]/.test (value) ? `'${value}` : value)}</td>`).join ('')
+ '</tr>').join ('') + '</table>'
event.clipboardData.setData ('text/plain', text)
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', html)
event.clipboardData.setData ('text/html', payload.html)
}
catch
{
@@ -140,10 +184,18 @@ const CountsSelectableTable = (
'[&>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
|| element.closest (interactive) != null || event.button !== 0) return
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 ()
@@ -153,6 +205,7 @@ const CountsSelectableTable = (
const point = pointFor (element)
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 })
@@ -169,6 +222,8 @@ const CountsSelectableTable = (
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)
@@ -182,6 +237,10 @@ const CountsSelectableTable = (
if (!dragging.current || event.buttons === 0) return
const point = pointFor (document.elementFromPoint (event.clientX, 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 })
@@ -191,10 +250,27 @@ const CountsSelectableTable = (
if (event.target === event.currentTarget) finish ()
}}
onContextMenu={event => {
if (touchPress.current != null || touchArmed.current) event.preventDefault ()
const element = event.target as Element
if (element.closest ('table') !== event.currentTarget) return
const point = pointFor (element)
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)
@@ -221,6 +297,8 @@ const CountsSelectableTable = (
<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>)
}