このコミットが含まれているのは:
2026-10-27 19:54:31 +09:00
コミット 4f117c7ba3
7個のファイルの変更、366行の追加、127行の削除
+31 -18
ファイルの表示
@@ -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,21 +231,26 @@ 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
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) unrestricted_axes = CountsAxes.new(source)
relation = source.scope relation = source.scope
joins = unrestricted_axes.joins joins = unrestricted_axes.joins
relation = relation.joins(joins) unless joins.empty? relation = relation.joins(joins) unless joins.empty?
expressions = unrestricted_axes.expressions 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| selected = expressions.each_with_index.map { |expression, index|
"#{ expression } AS a#{ index }" "#{ expression } AS a#{ index }"
} }
@@ -258,10 +263,7 @@ class CountsTableQuery
SELECT #{ keys.join(', ') } FROM cells GROUP BY #{ keys.join(', ') } SELECT #{ keys.join(', ') } FROM cells GROUP BY #{ keys.join(', ') }
) output_groups ) output_groups
SQL SQL
rows = output_row_count(prefix, keys, CountsPivotLayout.depths(keys, @sort), groups) output_row_count(prefix, keys, CountsPivotLayout.depths(keys, @sort), groups)
end
end
@output_totals = { output_count: rows, excluded_count: denied }
end end
def locale = @locale ||= Locale.find_by!(code: @params.fetch('locale', 'ja')) def locale = @locale ||= Locale.find_by!(code: @params.fetch('locale', 'ja'))
@@ -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?
+47 -28
ファイルの表示
@@ -71,30 +71,37 @@ 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')}
aria-label={aria.text}
lang={aria.language}
onChange={event => patch (index, { [key]: event.target.value })}> onChange={event => patch (index, { [key]: event.target.value })}>
<option value=""/> <option value=""/>
{options.map (value => <option key={value} value={value} {options.map (value => <option key={value} value={value}
@@ -103,37 +110,49 @@ const CountsAdditionalConditions = ({ params, update }: {
</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">
{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} <input type="checkbox" checked={condition.blank ?? false}
onChange={event => patch (index, { blank: event.target.checked })}/> onChange={event => patch (index, { blank: event.target.checked })}/>
{label ('blank')}</label> {label ('blank')}</label>
<button type="button" aria-label={message ('remove').text} <Button type="button" variant="ghost" size="sm" className="h-7 px-1"
onClick={() => write (conditions.filter ( aria-label={message ('remove').text}
(_, position) => index !== position))}>×</button> onClick={() => write (conditions.filter ((_, position) => index !== position))}>
×</Button>
</div> </div>
})} </div>))}
</fieldset> </fieldset>
} }
+18 -8
ファイルの表示
@@ -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>)}
+192 -33
ファイルの表示
@@ -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 }, [])
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 delete cell.dataset.countsText
const text = window.getSelection () const text = window.getSelection ()
if (text != null && cell.contains (text.anchorNode)) text.removeAllRanges () if (text != null && cell.contains (text.anchorNode)) text.removeAllRanges ()
textCell.current = null }
}, []) }, [identity, textMode])
useEffect (() => () => clearTextSelection (), [identity, clearTextSelection]) 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>)
+26
ファイルの表示
@@ -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;
} }
+14 -3
ファイルの表示
@@ -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": "等しくない",
+3 -2
ファイルの表示
@@ -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')}`}