このコミットが含まれているのは:
@@ -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 =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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>)
|
||||
}
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする