このコミットが含まれているのは:
@@ -98,10 +98,10 @@ class CountsTableQuery
|
||||
extras << "sort#{ index }.count AS sort#{ index }"
|
||||
"sort#{ index } IS NULL ASC, sort#{ index } #{ direction.upcase }"
|
||||
elsif row_keys.include?(key)
|
||||
"#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"
|
||||
axis_order_sql(key, direction)
|
||||
end
|
||||
end
|
||||
order_rows = row_sort + row_keys.map { "#{ _1 } IS NULL, #{ _1 }" }
|
||||
order_rows = row_sort + row_keys.map { axis_order_sql(_1) }
|
||||
order_rows = ['singleton ASC'] if order_rows.empty?
|
||||
grouped = "SELECT #{ row_selection } FROM cells #{ row_group }"
|
||||
selection = (['grouped.*'] + extras).join(', ')
|
||||
@@ -115,10 +115,10 @@ class CountsTableQuery
|
||||
order_cols = @sort.filter_map do |key, direction|
|
||||
next unless key.match?(/\Aa[0-9]+\z/) && !row_keys.include?(key)
|
||||
|
||||
"cells.#{ key } #{ direction.upcase }"
|
||||
axis_order_sql(key, direction, qualifier: 'cells')
|
||||
end
|
||||
order = ['page_rows.position'] + order_cols +
|
||||
axes.all.each_index.map { "cells.a#{ _1 }" }
|
||||
axes.all.each_index.map { axis_order_sql("a#{ _1 }", qualifier: 'cells') }
|
||||
records = @connection.select_all(<<~SQL)
|
||||
#{ prefix }, row_groups AS (#{ group_sql }),
|
||||
numbered AS (SELECT row_groups.*,
|
||||
@@ -231,39 +231,41 @@ class CountsTableQuery
|
||||
# detail pagination continue to use the authorised population exclusively.
|
||||
source = CountsPopulation.new(
|
||||
@params.merge('execution_at' => population.at.iso8601(6)), authorisation_flg: false)
|
||||
records = source.scope.except(:select).optimizer_hints('MAX_EXECUTION_TIME(10000)').count
|
||||
denied = source.scope.except(:select)
|
||||
.where(Arel.sql("NOT (#{ HistoryAccess.sql(@definition.model, 'v') })"))
|
||||
.optimizer_hints('MAX_EXECUTION_TIME(10000)').count
|
||||
rows = records
|
||||
if mode == 'summary'
|
||||
unrestricted_axes = CountsAxes.new(source)
|
||||
relation = source.scope
|
||||
joins = unrestricted_axes.joins
|
||||
relation = relation.joins(joins) unless joins.empty?
|
||||
expressions = unrestricted_axes.expressions
|
||||
keys = axes.rows.each_index.map { "a#{ _1 }" }
|
||||
if keys.empty?
|
||||
rows = 1
|
||||
else
|
||||
selected = expressions.each_with_index.map { |expression, index|
|
||||
"#{ expression } AS a#{ index }"
|
||||
}
|
||||
sql = relation.except(:select).select(Arel.sql(selected.join(', ')))
|
||||
.distinct.to_sql
|
||||
prefix = "WITH cells AS (#{ sql })"
|
||||
groups = @connection.select_value(<<~SQL).to_i
|
||||
#{ prefix }
|
||||
SELECT /*+ MAX_EXECUTION_TIME(10000) */ COUNT(*) FROM (
|
||||
SELECT #{ keys.join(', ') } FROM cells GROUP BY #{ keys.join(', ') }
|
||||
) output_groups
|
||||
SQL
|
||||
rows = output_row_count(prefix, keys, CountsPivotLayout.depths(keys, @sort), groups)
|
||||
end
|
||||
end
|
||||
rows = if mode == 'details'
|
||||
source.scope.except(:select).optimizer_hints('MAX_EXECUTION_TIME(10000)').count
|
||||
else
|
||||
summary_output_count(source)
|
||||
end
|
||||
@output_totals = { output_count: rows, excluded_count: denied }
|
||||
end
|
||||
|
||||
def summary_output_count(source)
|
||||
keys = axes.rows.each_index.map { "a#{ _1 }" }
|
||||
return 1 if keys.empty?
|
||||
|
||||
unrestricted_axes = CountsAxes.new(source)
|
||||
relation = source.scope
|
||||
joins = unrestricted_axes.joins
|
||||
relation = relation.joins(joins) unless joins.empty?
|
||||
expressions = unrestricted_axes.expressions
|
||||
selected = expressions.each_with_index.map { |expression, index|
|
||||
"#{ expression } AS a#{ index }"
|
||||
}
|
||||
sql = relation.except(:select).select(Arel.sql(selected.join(', ')))
|
||||
.distinct.to_sql
|
||||
prefix = "WITH cells AS (#{ sql })"
|
||||
groups = @connection.select_value(<<~SQL).to_i
|
||||
#{ prefix }
|
||||
SELECT /*+ MAX_EXECUTION_TIME(10000) */ COUNT(*) FROM (
|
||||
SELECT #{ keys.join(', ') } FROM cells GROUP BY #{ keys.join(', ') }
|
||||
) output_groups
|
||||
SQL
|
||||
output_row_count(prefix, keys, CountsPivotLayout.depths(keys, @sort), groups)
|
||||
end
|
||||
|
||||
def locale = @locale ||= Locale.find_by!(code: @params.fetch('locale', 'ja'))
|
||||
|
||||
def limited_columns(prefix)
|
||||
@@ -272,9 +274,7 @@ class CountsTableQuery
|
||||
|
||||
configured = @sort.select { |key, _direction| keys.include?(key) }
|
||||
directions = (configured + keys.map { [_1, 'asc'] }).uniq { _1.first }
|
||||
order = directions.map { |key, direction|
|
||||
"#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"
|
||||
}.join(', ')
|
||||
order = directions.map { |key, direction| axis_order_sql(key, direction) }.join(', ')
|
||||
custom = QueryState.records(@params, 'column_order')
|
||||
if custom.any?
|
||||
raise ArgumentError unless custom.map { _1['value'] }.uniq.length == custom.length
|
||||
@@ -311,6 +311,19 @@ class CountsTableQuery
|
||||
source.select(Arel.sql(selected.join(', '))).to_sql
|
||||
end
|
||||
|
||||
def axis_order_sql(key, direction = 'asc', qualifier: nil)
|
||||
column = qualifier ? "#{ qualifier }.#{ key }" : key
|
||||
expression = column
|
||||
if axes.all.fetch(key.delete_prefix('a').to_i).fetch('field') == 'category'
|
||||
# Tag's enum preserves the same category order as TagsController.
|
||||
cases = Tag.categories.keys.each_with_index.map { |category, index|
|
||||
"WHEN #{ @connection.quote(category) } THEN #{ index }"
|
||||
}.join(' ')
|
||||
expression = "CASE #{ column } #{ cases } ELSE #{ Tag.categories.length } END"
|
||||
end
|
||||
"#{ column } IS NULL ASC, #{ expression } #{ direction.upcase }"
|
||||
end
|
||||
|
||||
def measure_values(key)
|
||||
records = QueryState.records(@params, 'measures').select { _1['key'] == key }
|
||||
raise ArgumentError if records.empty?
|
||||
|
||||
@@ -71,69 +71,88 @@ const CountsAdditionalConditions = ({ params, update }: {
|
||||
const dialogue = useDialogue ()
|
||||
const conditions = readCountState<CountsCondition[]> (params, 'conditions', [])
|
||||
const metadata = definition.filterFields ?? definition.fields
|
||||
const fields = metadata.filter (field => field.operators.length > 0)
|
||||
const fieldName = (key: string) => (metadata.find (item => item.key === key)?.labels
|
||||
?? [countsFieldLabel (key)]).map (key => message (key).text).join (' › ')
|
||||
const write = (next: CountsCondition[]) => update ('conditions', encodeCountState ('conditions', next))
|
||||
const write = (next: CountsCondition[]) => update (
|
||||
'conditions', encodeCountState ('conditions', next))
|
||||
const replace = (index: number, condition: CountsCondition) => write (
|
||||
conditions.map ((item, position) => position === index ? condition : item))
|
||||
const patch = (index: number, change: Partial<CountsCondition>) =>
|
||||
replace (index, { ...conditions[index], ...change })
|
||||
const edit = (index?: number) => {
|
||||
const initial = index == null ? undefined : conditions[index]
|
||||
const save = (condition: CountsCondition) => {
|
||||
write (index == null ? [...conditions, condition]
|
||||
: conditions.map ((item, position) => position === index ? condition : item))
|
||||
if (index == null) write ([...conditions, condition])
|
||||
else replace (index, condition)
|
||||
}
|
||||
dialogue.form ({ title: label ('additionalConditions'), footerLayout: 'inline',
|
||||
body: controls => <ConditionEditor definition={definition}
|
||||
initial={initial} controls={controls} save={save}/> })
|
||||
void dialogue.form ({ title: label ('additionalConditions'), footerLayout: 'inline',
|
||||
body: controls => <ConditionEditor definition={definition}
|
||||
initial={initial} controls={controls} save={save}/> })
|
||||
}
|
||||
const patch = (index: number, change: Partial<CountsCondition>) => write (
|
||||
conditions.map ((item, position) => position === index ? { ...item, ...change } : item))
|
||||
const valueInput = (condition: CountsCondition, index: number, key: 'value' | 'end') => {
|
||||
const type = countsFilterFields (definition)[condition.field]
|
||||
const aria = message (key === 'end' ? 'through' : 'value')
|
||||
const aria = message (key === 'end' ? 'through'
|
||||
: (condition.operator === 'range' ? 'from' : 'value'))
|
||||
if (type === 'boolean' || condition.field === 'event_type')
|
||||
{
|
||||
const options: string[] = type === 'boolean' ? ['true', 'false'] : [...countsEventTypes]
|
||||
const namespace = type === 'boolean' ? 'truthValues' : 'events'
|
||||
return <select key={key} value={condition[key] ?? ''} disabled={condition.blank}
|
||||
className={inputClass (false, 'w-36 py-1')} aria-label={aria.text}
|
||||
onChange={event => patch (index, { [key]: event.target.value })}>
|
||||
<option value=""/>
|
||||
{options.map (value => <option key={value} value={value}
|
||||
lang={message (`${namespace}.${value}`).language}>
|
||||
{message (`${namespace}.${value}`).text}</option>)}
|
||||
return <select value={condition[key] ?? ''} disabled={condition.blank}
|
||||
className={inputClass (false, 'h-7 w-36 min-w-0 max-w-full py-1')}
|
||||
aria-label={aria.text}
|
||||
lang={aria.language}
|
||||
onChange={event => patch (index, { [key]: event.target.value })}>
|
||||
<option value=""/>
|
||||
{options.map (value => <option key={value} value={value}
|
||||
lang={message (`${namespace}.${value}`).language}>
|
||||
{message (`${namespace}.${value}`).text}</option>)}
|
||||
</select>
|
||||
}
|
||||
if (type === 'datetime')
|
||||
return <DateTimeField precision="second" key={key}
|
||||
value={condition[key] ?? ''} disabled={condition.blank}
|
||||
aria-label={aria.text} lang={aria.language}
|
||||
onChange={value => patch (index, { [key]: value ?? '' })}/>
|
||||
return <input key={key} value={condition[key] ?? ''} disabled={condition.blank}
|
||||
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
|
||||
className={inputClass (false, 'w-36 py-1')}
|
||||
aria-label={aria.text} lang={aria.language}
|
||||
onChange={event => patch (index, { [key]: event.target.value })}/>
|
||||
return <DateTimeField precision="second" className="h-7 w-48 min-w-0 max-w-full py-1"
|
||||
value={condition[key] ?? ''} disabled={condition.blank}
|
||||
aria-label={aria.text} lang={aria.language}
|
||||
onChange={value => patch (index, { [key]: value ?? '' })}/>
|
||||
return <input value={condition[key] ?? ''} disabled={condition.blank}
|
||||
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
|
||||
className={inputClass (false, 'h-7 w-36 min-w-0 max-w-full py-1')}
|
||||
aria-label={aria.text} lang={aria.language}
|
||||
onChange={event => patch (index, { [key]: event.target.value })}/>
|
||||
}
|
||||
return <fieldset className="space-y-1">
|
||||
return <fieldset className="min-w-0 space-y-1">
|
||||
<legend className="flex items-center gap-2">{label ('additionalConditions')}
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => edit ()}>
|
||||
{label ('add')}</Button></legend>
|
||||
{conditions.map ((condition, index) => {
|
||||
return <div key={index} className="flex flex-wrap items-center gap-1">
|
||||
<button type="button" onClick={() => edit (index)}>
|
||||
{fieldName (condition.field)}
|
||||
{' · '}{label (`operators.${condition.operator}`)}
|
||||
</button>
|
||||
{(condition.operator === 'range' ? ['value', 'end'] as const : ['value'] as const)
|
||||
.map (key => valueInput (condition, index, key))}
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<input type="checkbox" checked={condition.blank ?? false}
|
||||
onChange={event => patch (index, { blank: event.target.checked })}/>
|
||||
{label ('blank')}</label>
|
||||
<button type="button" aria-label={message ('remove').text}
|
||||
onClick={() => write (conditions.filter (
|
||||
(_, position) => index !== position))}>×</button>
|
||||
</div>
|
||||
})}
|
||||
<Button type="button" variant="outline" size="sm" disabled={fields.length === 0}
|
||||
onClick={() => edit ()}>{label ('add')}</Button></legend>
|
||||
{conditions.map ((condition, index) => (
|
||||
<div key={index} className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1
|
||||
border-b border-slate-200 py-1 dark:border-slate-700">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => edit (index)}
|
||||
aria-label={`${fieldName (condition.field)} ${message (
|
||||
`operators.${condition.operator}`).text}`}
|
||||
className="h-7 min-w-0 max-w-full justify-start gap-2 whitespace-normal px-1 text-left">
|
||||
<span className="min-w-0 break-words">{fieldName (condition.field)}</span>
|
||||
{condition.operator !== 'range' && <span aria-hidden="true" className="shrink-0">
|
||||
{label (`operatorSymbols.${condition.operator}`)}</span>}
|
||||
</Button>
|
||||
<div className="inline-flex min-w-0 max-w-full items-center gap-1">
|
||||
{valueInput (condition, index, 'value')}
|
||||
{condition.operator === 'range' && <>
|
||||
<span aria-hidden="true" className="shrink-0">{label ('operatorSymbols.range')}</span>
|
||||
{valueInput (condition, index, 'end')}
|
||||
</>}
|
||||
</div>
|
||||
<div className="inline-flex shrink-0 items-center gap-1">
|
||||
<label className="inline-flex items-center gap-1 text-sm">
|
||||
<input type="checkbox" checked={condition.blank ?? false}
|
||||
onChange={event => patch (index, { blank: event.target.checked })}/>
|
||||
{label ('blank')}</label>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-1"
|
||||
aria-label={message ('remove').text}
|
||||
onClick={() => write (conditions.filter ((_, position) => index !== position))}>
|
||||
×</Button>
|
||||
</div>
|
||||
</div>))}
|
||||
</fieldset>
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,13 @@ const CountsControls = (
|
||||
<fieldset className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b py-1
|
||||
dark:border-slate-700">
|
||||
<legend className="sr-only">{label ('displaySettings')}</legend>
|
||||
<label className="inline-flex items-center gap-1 text-sm">
|
||||
{label ('fields.limit')}
|
||||
<input value={draft.get ('limit') ?? '20'}
|
||||
aria-label={message ('fields.limit').text}
|
||||
className={inputClass (false, 'w-20 py-1')}
|
||||
onChange={event => update ('limit', event.target.value)}/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1 text-sm">
|
||||
<input type="checkbox" checked={draft.get ('row_numbers') === '1'}
|
||||
onChange={event => {
|
||||
@@ -172,10 +179,10 @@ const CountsControls = (
|
||||
</label>}
|
||||
</fieldset>
|
||||
<AnimatedCollapse id={id} open={open}>
|
||||
<div className="max-h-[35dvh] overflow-y-auto overscroll-contain
|
||||
border-b py-2 dark:border-slate-700 md:max-h-[40dvh]">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<fieldset className="min-w-0" data-counts-output>
|
||||
<div className="grid gap-3 border-b py-2 dark:border-slate-700 md:grid-cols-2">
|
||||
<div className="max-h-[20dvh] min-h-0 min-w-0 overflow-y-auto overscroll-contain
|
||||
md:max-h-[40dvh]" style={{ direction: 'rtl' }}>
|
||||
<fieldset className="min-w-0" data-counts-output style={{ direction: 'ltr' }}>
|
||||
{mode === 'summary' ? <CountsAxes params={draft} update={update}/>
|
||||
: <>
|
||||
<legend className="mb-1 font-semibold">{label ('output')}</legend>
|
||||
@@ -185,6 +192,9 @@ const CountsControls = (
|
||||
}}/>
|
||||
</>}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="max-h-[20dvh] min-h-0 min-w-0 overflow-y-auto overscroll-contain
|
||||
md:max-h-[40dvh]" style={{ direction: 'ltr' }}>
|
||||
<fieldset className="min-w-0 space-y-2" data-counts-filters>
|
||||
<legend className="font-semibold">{label ('filters')}</legend>
|
||||
{history && <div className="flex min-w-0 max-w-full items-center gap-1">
|
||||
@@ -197,12 +207,12 @@ const CountsControls = (
|
||||
className="min-w-0 flex-1" onChange={value => update ('through', value ?? '')}/>
|
||||
</div>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['resource_id', 'user_id', 'limit'].map (key => <FormField key={key}
|
||||
{['resource_id', 'user_id'].map (key => <FormField key={key}
|
||||
label={label (key === 'resource_id' ? 'columns.resourceId'
|
||||
: (key === 'user_id' ? 'fields.actor' : 'fields.limit'))}>
|
||||
{() => <input value={draft.get (key) ?? (key === 'limit' ? '20' : '')}
|
||||
: 'fields.actor')}>
|
||||
{() => <input value={draft.get (key) ?? ''}
|
||||
aria-label={message (key === 'resource_id' ? 'columns.resourceId'
|
||||
: (key === 'user_id' ? 'fields.actor' : 'fields.limit')).text}
|
||||
: 'fields.actor').text}
|
||||
className={inputClass (false, 'w-28 py-1')}
|
||||
onChange={event => update (key, event.target.value)}/>}
|
||||
</FormField>)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import CountsCellMenu from '@/components/counts/CountsCellMenu'
|
||||
import { countsClipboard, writeCountsClipboard } from '@/lib/countsClipboard'
|
||||
@@ -13,6 +14,8 @@ import type { CountsMenuAction } from '@/components/counts/CountsCellMenu'
|
||||
|
||||
type Point = { row: number; column: number }
|
||||
type Selection = { identity: string; anchor: Point; end: Point }
|
||||
type TextMode = { identity: string; cell: HTMLTableCellElement; nodes: Node[] }
|
||||
type Caret = { cell: HTMLTableCellElement; left: number; top: number; height: number }
|
||||
const interactive = 'a,button,input,select,textarea,summary,[contenteditable],[data-counts-resize]'
|
||||
|
||||
|
||||
@@ -53,7 +56,10 @@ const CountsSelectableTable = (
|
||||
) => {
|
||||
const id = useId ()
|
||||
const root = useRef<HTMLTableElement> (null)
|
||||
const textCell = useRef<HTMLTableCellElement | null> (null)
|
||||
const [textMode, setTextMode] = useState<TextMode | null> (null)
|
||||
const textCell = textMode?.identity === identity ? textMode.cell : null
|
||||
const [caret, setCaret] = useState<Caret | null> (null)
|
||||
const mousePress = useRef<{ x: number; y: number } | null> (null)
|
||||
const dragging = useRef (false)
|
||||
const touchArmed = useRef (false)
|
||||
const touchPress = useRef<{ x: number; y: number
|
||||
@@ -66,20 +72,95 @@ const CountsSelectableTable = (
|
||||
const layout = useCountsTableLayout ()
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const clearTextSelection = useCallback (() => {
|
||||
const cell = textCell.current
|
||||
if (cell == null) return
|
||||
delete cell.dataset.countsText
|
||||
const text = window.getSelection ()
|
||||
if (text != null && cell.contains (text.anchorNode)) text.removeAllRanges ()
|
||||
textCell.current = null
|
||||
setTextMode (null)
|
||||
}, [])
|
||||
useEffect (() => () => clearTextSelection (), [identity, clearTextSelection])
|
||||
useLayoutEffect (() => {
|
||||
if (textMode == null) return
|
||||
const { cell, nodes } = textMode
|
||||
const table = root.current
|
||||
if (table == null || textMode.identity !== identity || !table.contains (cell))
|
||||
{
|
||||
setTextMode (null)
|
||||
return
|
||||
}
|
||||
cell.dataset.countsText = ''
|
||||
table.focus ({ preventScroll: true })
|
||||
const range = document.createRange ()
|
||||
range.setStart (nodes[0], 0)
|
||||
const last = nodes[nodes.length - 1]
|
||||
range.setEnd (last, last.textContent?.length ?? 0)
|
||||
const text = window.getSelection ()
|
||||
text?.removeAllRanges ()
|
||||
text?.addRange (range)
|
||||
return () => {
|
||||
delete cell.dataset.countsText
|
||||
const text = window.getSelection ()
|
||||
if (text != null && cell.contains (text.anchorNode)) text.removeAllRanges ()
|
||||
}
|
||||
}, [identity, textMode])
|
||||
useLayoutEffect (() => {
|
||||
if (textCell == null) return
|
||||
const updateCaret = () => {
|
||||
const text = window.getSelection ()
|
||||
if (!document.hasFocus () || text == null || !text.isCollapsed || text.rangeCount === 0
|
||||
|| !textCell.contains (text.focusNode))
|
||||
{
|
||||
setCaret (null)
|
||||
return
|
||||
}
|
||||
const range = text.getRangeAt (0).cloneRange ()
|
||||
const rect = range.getBoundingClientRect ()
|
||||
const node = text.focusNode
|
||||
const offset = text.focusOffset
|
||||
const probe = range.cloneRange ()
|
||||
if (rect.height === 0 && node?.nodeType === Node.TEXT_NODE && node.textContent)
|
||||
{
|
||||
if (offset < node.textContent.length) probe.setEnd (node, offset + 1)
|
||||
else probe.setStart (node, offset - 1)
|
||||
}
|
||||
const bounds = rect.height > 0 ? rect : probe.getBoundingClientRect ()
|
||||
if (bounds.height === 0)
|
||||
{
|
||||
setCaret (null)
|
||||
return
|
||||
}
|
||||
const cell = textCell.getBoundingClientRect ()
|
||||
const left = (rect.height === 0 && offset === node?.textContent?.length)
|
||||
? bounds.right : bounds.left
|
||||
setCaret ({ cell: textCell,
|
||||
left: left - cell.left - textCell.clientLeft + textCell.scrollLeft,
|
||||
top: bounds.top - cell.top - textCell.clientTop + textCell.scrollTop,
|
||||
height: bounds.height })
|
||||
}
|
||||
updateCaret ()
|
||||
document.addEventListener ('selectionchange', updateCaret)
|
||||
document.addEventListener ('scroll', updateCaret, true)
|
||||
window.addEventListener ('resize', updateCaret)
|
||||
window.addEventListener ('focus', updateCaret)
|
||||
window.addEventListener ('blur', updateCaret)
|
||||
return () => {
|
||||
document.removeEventListener ('selectionchange', updateCaret)
|
||||
document.removeEventListener ('scroll', updateCaret, true)
|
||||
window.removeEventListener ('resize', updateCaret)
|
||||
window.removeEventListener ('focus', updateCaret)
|
||||
window.removeEventListener ('blur', updateCaret)
|
||||
}
|
||||
}, [textCell])
|
||||
useEffect (() => {
|
||||
if (textCell == null) return
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (!textCell.contains (event.target as Node)) clearTextSelection ()
|
||||
}
|
||||
document.addEventListener ('pointerdown', outside, true)
|
||||
return () => document.removeEventListener ('pointerdown', outside, true)
|
||||
}, [clearTextSelection, textCell])
|
||||
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 = () => {
|
||||
mousePress.current = null
|
||||
dragging.current = false
|
||||
touchArmed.current = false
|
||||
if (touchPress.current != null) clearTimeout (touchPress.current.timer)
|
||||
@@ -98,6 +179,7 @@ const CountsSelectableTable = (
|
||||
}, [])
|
||||
useLayoutEffect (() => {
|
||||
if (root.current == null) return
|
||||
if (textCell != null && !root.current.contains (textCell)) setTextMode (null)
|
||||
const grid = cellGrid (root.current)
|
||||
const assigned = new Set<HTMLTableCellElement> ()
|
||||
for (const [row, cells] of grid.entries ())
|
||||
@@ -112,14 +194,14 @@ const CountsSelectableTable = (
|
||||
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 = ''
|
||||
if (selected && cell !== textCell) 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])
|
||||
}, [bounds, children, id, textCell])
|
||||
const pointFor = (element: Element | null, y?: number): Point | null => {
|
||||
const table = root.current
|
||||
if (table == null || element == null || !table.contains (element)) return null
|
||||
@@ -140,6 +222,35 @@ const CountsSelectableTable = (
|
||||
}
|
||||
return null
|
||||
}
|
||||
const enterTextSelection = (point: Point) => {
|
||||
const table = root.current
|
||||
const cell = table == null ? null : cellAt (table, point)
|
||||
if (table == null || cell == null) return
|
||||
// Structured cells retain their independent inner grid instead of selecting its UI.
|
||||
const nested = cell.querySelector ('table')
|
||||
if (nested != null)
|
||||
{
|
||||
nested.focus ({ preventScroll: true })
|
||||
return
|
||||
}
|
||||
const walker = document.createTreeWalker (cell, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: node => {
|
||||
const parent = node.parentElement
|
||||
const auxiliaryFlg = parent?.closest (
|
||||
'[hidden],[aria-hidden="true"],.sr-only,svg,[data-counts-resize]') != null
|
||||
return (parent?.closest ('table') === table && node.textContent?.trim ()
|
||||
&& !auxiliaryFlg)
|
||||
? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
|
||||
} })
|
||||
const nodes: Node[] = []
|
||||
for (let node = walker.nextNode (); node != null; node = walker.nextNode ())
|
||||
nodes.push (node)
|
||||
if (nodes.length === 0) return
|
||||
finish ()
|
||||
clearTextSelection ()
|
||||
setMenu (null)
|
||||
setTextMode ({ identity, cell, nodes })
|
||||
}
|
||||
const closeMenu = useCallback (() => {
|
||||
setMenu (null)
|
||||
root.current?.focus ({ preventScroll: true })
|
||||
@@ -209,9 +320,9 @@ const CountsSelectableTable = (
|
||||
}
|
||||
const copy = (event: ClipboardEvent<HTMLTableElement>) => {
|
||||
const text = window.getSelection ()
|
||||
if (textCell.current != null && text != null && !text.isCollapsed
|
||||
&& textCell.current.contains (text.anchorNode)
|
||||
&& textCell.current.contains (text.focusNode))
|
||||
if (textCell != null && text != null && !text.isCollapsed
|
||||
&& textCell.contains (text.anchorNode)
|
||||
&& textCell.contains (text.focusNode))
|
||||
{
|
||||
event.stopPropagation ()
|
||||
return
|
||||
@@ -254,21 +365,16 @@ const CountsSelectableTable = (
|
||||
const element = event.target as Element
|
||||
if (element.closest ('table') !== event.currentTarget
|
||||
|| element.closest (interactive) != null) return
|
||||
const cell = element.closest ('td')
|
||||
if (cell == null) return
|
||||
if (textCell?.contains (element))
|
||||
{
|
||||
event.stopPropagation ()
|
||||
return
|
||||
}
|
||||
const point = pointFor (element, event.clientY)
|
||||
if (point == null) return
|
||||
event.preventDefault ()
|
||||
event.stopPropagation ()
|
||||
finish ()
|
||||
clearTextSelection ()
|
||||
setStored (null)
|
||||
setMenu (null)
|
||||
textCell.current = cell
|
||||
cell.dataset.countsText = ''
|
||||
const range = document.createRange ()
|
||||
range.selectNodeContents (cell)
|
||||
const text = window.getSelection ()
|
||||
text?.removeAllRanges ()
|
||||
text?.addRange (range)
|
||||
enterTextSelection (point)
|
||||
}}
|
||||
onClickCapture={event => {
|
||||
if ((event.target as Element).closest ('table') !== event.currentTarget
|
||||
@@ -281,7 +387,7 @@ const CountsSelectableTable = (
|
||||
const element = event.target as Element
|
||||
if (element.closest ('table') !== event.currentTarget) return
|
||||
suppressClick.current = false
|
||||
if (textCell.current != null && textCell.current.contains (element))
|
||||
if (textCell != null && textCell.contains (element))
|
||||
{
|
||||
event.stopPropagation ()
|
||||
return
|
||||
@@ -304,9 +410,7 @@ const CountsSelectableTable = (
|
||||
finish ()
|
||||
if (event.pointerType === 'mouse')
|
||||
{
|
||||
event.preventDefault ()
|
||||
dragging.current = true
|
||||
event.currentTarget.setPointerCapture (event.pointerId)
|
||||
mousePress.current = { x: event.clientX, y: event.clientY }
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -323,10 +427,22 @@ const CountsSelectableTable = (
|
||||
}
|
||||
}}
|
||||
onPointerMove={event => {
|
||||
if (mousePress.current != null)
|
||||
{
|
||||
if (event.buttons === 0) finish ()
|
||||
else if (!dragging.current && Math.hypot (
|
||||
event.clientX - mousePress.current.x, event.clientY - mousePress.current.y) > 5)
|
||||
{
|
||||
dragging.current = true
|
||||
suppressClick.current = true
|
||||
event.currentTarget.setPointerCapture (event.pointerId)
|
||||
}
|
||||
}
|
||||
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
|
||||
event.preventDefault ()
|
||||
const point = pointFor (document.elementFromPoint (event.clientX, event.clientY), event.clientY)
|
||||
if (point == null) return
|
||||
if (touchArmed.current && touchPress.current != null
|
||||
@@ -344,7 +460,7 @@ const CountsSelectableTable = (
|
||||
onContextMenu={event => {
|
||||
const element = event.target as Element
|
||||
if (element.closest ('table') !== event.currentTarget) return
|
||||
if (textCell.current != null && textCell.current.contains (element))
|
||||
if (textCell != null && textCell.contains (element))
|
||||
{
|
||||
event.stopPropagation ()
|
||||
return
|
||||
@@ -357,7 +473,8 @@ const CountsSelectableTable = (
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10'))
|
||||
if (textCell == null
|
||||
&& (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')))
|
||||
{
|
||||
const point = selection?.end ?? { row: 0, column: 0 }
|
||||
const rect = cellAt (event.currentTarget, point)?.getBoundingClientRect ()
|
||||
@@ -370,11 +487,47 @@ const CountsSelectableTable = (
|
||||
}
|
||||
if (event.key === 'Escape')
|
||||
{
|
||||
const textModeFlg = textCell != null
|
||||
clearTextSelection ()
|
||||
setStored (null)
|
||||
if (!textModeFlg) setStored (null)
|
||||
event.preventDefault ()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' && textCell == null && selection != null)
|
||||
{
|
||||
event.preventDefault ()
|
||||
enterTextSelection (selection.end)
|
||||
return
|
||||
}
|
||||
if (textCell != null)
|
||||
{
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return
|
||||
const text = window.getSelection ()
|
||||
if (text == null || typeof text.modify !== 'function') return
|
||||
event.preventDefault ()
|
||||
event.stopPropagation ()
|
||||
const anchor = text.anchorNode
|
||||
const offset = text.anchorOffset
|
||||
const backward = event.key === 'ArrowLeft' || event.key === 'ArrowUp'
|
||||
const vertical = event.key === 'ArrowUp' || event.key === 'ArrowDown'
|
||||
const direction = vertical ? (backward ? 'backward' : 'forward')
|
||||
: (backward ? 'left' : 'right')
|
||||
const grain = vertical ? 'line' : ((event.ctrlKey || event.metaKey) ? 'word' : 'character')
|
||||
text.modify (event.shiftKey ? 'extend' : 'move', direction, grain)
|
||||
if (!textCell.contains (text.focusNode))
|
||||
{
|
||||
const nodes = textMode?.nodes.filter (node => textCell.contains (node)) ?? []
|
||||
const edge = backward ? nodes[0] : nodes[nodes.length - 1]
|
||||
if (edge != null)
|
||||
{
|
||||
const end = backward ? 0 : edge.textContent?.length ?? 0
|
||||
if (event.shiftKey && anchor != null && textCell.contains (anchor))
|
||||
text.setBaseAndExtent (anchor, offset, edge, end)
|
||||
else text.setPosition (edge, end)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (textCell.current != 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
|
||||
@@ -391,11 +544,17 @@ const CountsSelectableTable = (
|
||||
cellAt (event.currentTarget, point)?.scrollIntoView ({
|
||||
block: 'nearest', inline: 'nearest' })
|
||||
}}>
|
||||
<caption id={`${id}-hint`} className="sr-only">{label ('selection.hint')}</caption>
|
||||
<caption className="sr-only">
|
||||
<span id={`${id}-hint`} className="sr-only">{label ('selection.hint')}</span>
|
||||
<span role="status">{textCell != null && label ('selection.textMode')}</span>
|
||||
</caption>
|
||||
<colgroup>{columns.map (key => (
|
||||
<col key={key} style={{ width: layout.width (family, key) }}/>))}</colgroup>
|
||||
{children}
|
||||
</table>
|
||||
{textCell != null && caret != null && caret.cell === textCell && createPortal (
|
||||
<span aria-hidden="true" className="counts-text-caret" style={{
|
||||
left: caret.left, top: caret.top, height: caret.height }}/>, textCell)}
|
||||
{menu?.identity === identity && <CountsCellMenu x={menu.x} y={menu.y}
|
||||
act={menuAction} close={closeMenu}/>}
|
||||
</CountsTableScope.Provider>)
|
||||
|
||||
@@ -3,11 +3,37 @@
|
||||
}
|
||||
|
||||
.counts-grid > tbody > tr > td[data-counts-text] {
|
||||
position: relative;
|
||||
outline: 1px solid #64748b;
|
||||
outline-offset: -1px;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.dark .counts-grid > tbody > tr > td[data-counts-text] {
|
||||
outline-color: #94a3b8;
|
||||
}
|
||||
|
||||
.counts-text-caret {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
background-color: currentColor;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
animation: counts-caret-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes counts-caret-blink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.counts-text-caret { animation: none; }
|
||||
}
|
||||
|
||||
.counts-grid > tbody > tr:nth-child(odd) > td:not([data-counts-selected]) {
|
||||
@apply bg-white text-slate-900;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"titles": {
|
||||
"posts": "広場の集計・明細",
|
||||
"tags": "タグの集計・明細",
|
||||
"nico_tags": "ニコニコタグの集計・明細",
|
||||
"posts": "広場集計",
|
||||
"tags": "タグ集計",
|
||||
"nico_tags": "外部タグ集計",
|
||||
"tag_descriptions": "タグ説明の集計・明細",
|
||||
"tag_groups": "タググループの集計・明細",
|
||||
"materials": "素材の集計・明細"
|
||||
@@ -99,6 +99,7 @@
|
||||
"notRegistered": "名称未登録",
|
||||
"selection": {
|
||||
"table": "集計・明細表",
|
||||
"textMode": "文字列選択中",
|
||||
"hint": "クリックでセル選択、ドラッグまたは Shift+クリックで範囲選択。タッチではセルを長押ししてドラッグ。Ctrl+C/Cmd+C でコピー。矢印キーで移動、Shift+矢印キーで範囲を拡張、Escape で解除。",
|
||||
"copy": "コピー",
|
||||
"copyHeaders": "見出し付きコピー",
|
||||
@@ -184,6 +185,16 @@
|
||||
"day": "日",
|
||||
"hour": "時"
|
||||
},
|
||||
"operatorSymbols": {
|
||||
"eq": "=",
|
||||
"ne": "≠",
|
||||
"gt": ">",
|
||||
"lt": "<",
|
||||
"ge": "≥",
|
||||
"le": "≤",
|
||||
"like": "LIKE",
|
||||
"range": "~"
|
||||
},
|
||||
"operators": {
|
||||
"eq": "等しい",
|
||||
"ne": "等しくない",
|
||||
|
||||
@@ -78,6 +78,7 @@ const CountsResourceContent = (
|
||||
const locale = useLocale ()
|
||||
const dialogue = useDialogue ()
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const title = message (`titles.${resource}`)
|
||||
const mode = params.get ('mode') ?? 'summary'
|
||||
const state = isCountsState (params)
|
||||
const cacheParams = new URLSearchParams (request)
|
||||
@@ -274,7 +275,7 @@ const CountsResourceContent = (
|
||||
<CountsTableLayout>
|
||||
<div data-sidebar-container
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden md:flex-row">
|
||||
<PageHead title={message (`titles.${resource}`)}/>
|
||||
<PageHead title={title} includeSiteName={false}/>
|
||||
<Button type="button" variant="ghost" size="sm" className="shrink-0 md:hidden"
|
||||
aria-expanded={sidebarOpen} aria-controls="counts-targets"
|
||||
onClick={() => setSidebarOpen (!sidebarOpen)}>{label ('target')}</Button>
|
||||
@@ -319,7 +320,7 @@ const CountsResourceContent = (
|
||||
</nav>
|
||||
</SidebarComponent>
|
||||
<MainArea animateLayout={false} className="flex min-h-0 min-w-0 flex-col p-2">
|
||||
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
|
||||
<PageTitle className="mb-1 text-base" lang={title.language}>{title.text}</PageTitle>
|
||||
<div ref={controlsArea} className="shrink-0"
|
||||
style={{ minHeight: definition == null ? controlsHeight : undefined }}>
|
||||
{definition != null && <CountsControls key={`${resource}:${params.get ('definition')}`}
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする