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