diff options
Diffstat (limited to 'packages/frontend/src/components/grid')
| -rw-r--r-- | packages/frontend/src/components/grid/MkCellTooltip.vue | 35 | ||||
| -rw-r--r-- | packages/frontend/src/components/grid/MkDataCell.vue | 418 | ||||
| -rw-r--r-- | packages/frontend/src/components/grid/MkDataRow.vue | 72 | ||||
| -rw-r--r-- | packages/frontend/src/components/grid/MkGrid.stories.impl.ts | 223 | ||||
| -rw-r--r-- | packages/frontend/src/components/grid/MkGrid.vue | 1374 | ||||
| -rw-r--r-- | packages/frontend/src/components/grid/MkHeaderCell.vue | 216 | ||||
| -rw-r--r-- | packages/frontend/src/components/grid/MkHeaderRow.vue | 60 | ||||
| -rw-r--r-- | packages/frontend/src/components/grid/MkNumberCell.vue | 61 | ||||
| -rw-r--r-- | packages/frontend/src/components/grid/cell-validators.ts | 110 | ||||
| -rw-r--r-- | packages/frontend/src/components/grid/cell.ts | 88 | ||||
| -rw-r--r-- | packages/frontend/src/components/grid/column.ts | 53 | ||||
| -rw-r--r-- | packages/frontend/src/components/grid/grid-event.ts | 46 | ||||
| -rw-r--r-- | packages/frontend/src/components/grid/grid-utils.ts | 215 | ||||
| -rw-r--r-- | packages/frontend/src/components/grid/grid.ts | 49 | ||||
| -rw-r--r-- | packages/frontend/src/components/grid/row.ts | 68 |
15 files changed, 3088 insertions, 0 deletions
diff --git a/packages/frontend/src/components/grid/MkCellTooltip.vue b/packages/frontend/src/components/grid/MkCellTooltip.vue new file mode 100644 index 0000000000..fd289c6cd9 --- /dev/null +++ b/packages/frontend/src/components/grid/MkCellTooltip.vue @@ -0,0 +1,35 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="250" @closed="emit('closed')"> + <div :class="$style.root"> + {{ content }} + </div> +</MkTooltip> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkTooltip from '@/components/MkTooltip.vue'; + +defineProps<{ + showing: boolean; + content: string; + targetElement: HTMLElement; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); +</script> + +<style lang="scss" module> +.root { + font-size: 0.9em; + text-align: left; + text-wrap: normal; +} +</style> diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue new file mode 100644 index 0000000000..e473b7c1af --- /dev/null +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -0,0 +1,418 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + v-if="cell.row.using" + ref="rootEl" + class="mk_grid_td" + :class="$style.cell" + :style="{ maxWidth: cellWidth, minWidth: cellWidth }" + :tabindex="-1" + data-grid-cell + :data-grid-cell-row="cell.row.index" + :data-grid-cell-col="cell.column.index" + @keydown="onCellKeyDown" + @dblclick.prevent="onCellDoubleClick" +> + <div + :class="[ + $style.root, + [(cell.violation.valid || cell.selected) ? {} : $style.error], + [cell.selected ? $style.selected : {}], + // 行が選択されているときは範囲選択色の適用を行側に任せる + [(cell.ranged && !cell.row.ranged) ? $style.ranged : {}], + [needsContentCentering ? $style.center : {}], + ]" + > + <div v-if="!editing" :class="[$style.contentArea]" :style="cellType === 'boolean' ? 'justify-content: center' : ''"> + <div ref="contentAreaEl" :class="$style.content"> + <div v-if="cellType === 'text'"> + {{ cell.value }} + </div> + <div v-if="cellType === 'number'"> + {{ cell.value }} + </div> + <div v-if="cellType === 'date'"> + {{ cell.value }} + </div> + <div v-else-if="cellType === 'boolean'"> + <div :class="[$style.bool, { + [$style.boolTrue]: cell.value === true, + 'ti ti-check': cell.value === true, + }]"></div> + </div> + <div v-else-if="cellType === 'image'"> + <img + :src="cell.value" + :alt="cell.value" + :class="$style.viewImage" + @load="emitContentSizeChanged" + /> + </div> + </div> + </div> + <div v-else ref="inputAreaEl" :class="$style.inputArea"> + <input + v-if="cellType === 'text'" + type="text" + :class="$style.editingInput" + :value="editingValue" + @input="onInputText" + @mousedown.stop + @contextmenu.stop + /> + <input + v-if="cellType === 'number'" + type="number" + :class="$style.editingInput" + :value="editingValue" + @input="onInputText" + @mousedown.stop + @contextmenu.stop + /> + <input + v-if="cellType === 'date'" + type="date" + :class="$style.editingInput" + :value="editingValue" + @input="onInputText" + @mousedown.stop + @contextmenu.stop + /> + </div> + </div> +</div> +</template> + +<script setup lang="ts"> +import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue'; +import { GridEventEmitter, Size } from '@/components/grid/grid.js'; +import { useTooltip } from '@/scripts/use-tooltip.js'; +import * as os from '@/os.js'; +import { CellValue, GridCell } from '@/components/grid/cell.js'; +import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js'; +import { GridRowSetting } from '@/components/grid/row.js'; + +const emit = defineEmits<{ + (ev: 'operation:beginEdit', sender: GridCell): void; + (ev: 'operation:endEdit', sender: GridCell): void; + (ev: 'change:value', sender: GridCell, newValue: CellValue): void; + (ev: 'change:contentSize', sender: GridCell, newSize: Size): void; +}>(); +const props = defineProps<{ + cell: GridCell, + rowSetting: GridRowSetting, + bus: GridEventEmitter, +}>(); + +const { cell, bus } = toRefs(props); + +const rootEl = shallowRef<InstanceType<typeof HTMLTableCellElement>>(); +const contentAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>(); +const inputAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>(); + +/** 値が編集中かどうか */ +const editing = ref<boolean>(false); +/** 編集中の値. {@link beginEditing}と{@link endEditing}内、および各inputタグやそのコールバックからの操作のみを想定する */ +const editingValue = ref<CellValue>(undefined); + +const cellWidth = computed(() => cell.value.column.width); +const cellType = computed(() => cell.value.column.setting.type); +const needsContentCentering = computed(() => { + switch (cellType.value) { + case 'boolean': + return true; + default: + return false; + } +}); + +watch(() => [cell.value.value], () => { + // 中身がセットされた直後はサイズが分からないので、次のタイミングで更新する + nextTick(emitContentSizeChanged); +}, { immediate: true }); + +watch(() => cell.value.selected, () => { + if (cell.value.selected) { + requestFocus(); + } +}); + +function onCellDoubleClick(ev: MouseEvent) { + switch (ev.type) { + case 'dblclick': { + beginEditing(ev.target as HTMLElement); + break; + } + } +} + +function onOutsideMouseDown(ev: MouseEvent) { + const isOutside = ev.target instanceof Node && !rootEl.value?.contains(ev.target); + if (isOutside || !equalCellAddress(cell.value.address, getCellAddress(ev.target as HTMLElement))) { + endEditing(true, false); + } +} + +function onCellKeyDown(ev: KeyboardEvent) { + if (!editing.value) { + ev.preventDefault(); + switch (ev.code) { + case 'NumpadEnter': + case 'Enter': + case 'F2': { + beginEditing(ev.target as HTMLElement); + break; + } + } + } else { + switch (ev.code) { + case 'Escape': { + endEditing(false, true); + break; + } + case 'NumpadEnter': + case 'Enter': { + if (!ev.isComposing) { + endEditing(true, true); + } + } + } + } +} + +function onInputText(ev: Event) { + editingValue.value = (ev.target as HTMLInputElement).value; +} + +function onForceRefreshContentSize() { + emitContentSizeChanged(); +} + +function registerOutsideMouseDown() { + unregisterOutsideMouseDown(); + addEventListener('mousedown', onOutsideMouseDown); +} + +function unregisterOutsideMouseDown() { + removeEventListener('mousedown', onOutsideMouseDown); +} + +async function beginEditing(target: HTMLElement) { + if (editing.value || !cell.value.selected || !cell.value.column.setting.editable) { + return; + } + + if (cell.value.column.setting.customValueEditor) { + emit('operation:beginEdit', cell.value); + const newValue = await cell.value.column.setting.customValueEditor( + cell.value.row, + cell.value.column, + cell.value.value, + target, + ); + emit('operation:endEdit', cell.value); + + if (newValue !== cell.value.value) { + emitValueChange(newValue); + } + + requestFocus(); + } else { + switch (cellType.value) { + case 'number': + case 'date': + case 'text': { + editingValue.value = cell.value.value; + editing.value = true; + registerOutsideMouseDown(); + emit('operation:beginEdit', cell.value); + + await nextTick(() => { + // inputの展開後にフォーカスを当てたい + if (inputAreaEl.value) { + (inputAreaEl.value.querySelector('*') as HTMLElement).focus(); + } + }); + break; + } + case 'boolean': { + // とくに特殊なUIは設けず、トグルするだけ + emitValueChange(!cell.value.value); + break; + } + } + } +} + +function endEditing(applyValue: boolean, requireFocus: boolean) { + if (!editing.value) { + return; + } + + const newValue = editingValue.value; + editingValue.value = undefined; + + emit('operation:endEdit', cell.value); + unregisterOutsideMouseDown(); + + if (applyValue && newValue !== cell.value.value) { + emitValueChange(newValue); + } + + editing.value = false; + + if (requireFocus) { + requestFocus(); + } +} + +function requestFocus() { + nextTick(() => { + rootEl.value?.focus(); + }); +} + +function emitValueChange(newValue: CellValue) { + const _cell = cell.value; + emit('change:value', _cell, newValue); +} + +function emitContentSizeChanged() { + emit('change:contentSize', cell.value, { + width: contentAreaEl.value?.clientWidth ?? 0, + height: contentAreaEl.value?.clientHeight ?? 0, + }); +} + +useTooltip(rootEl, (showing) => { + if (cell.value.violation.valid) { + return; + } + + const content = cell.value.violation.violations.filter(it => !it.valid).map(it => it.result.message).join('\n'); + const result = os.popup(defineAsyncComponent(() => import('@/components/grid/MkCellTooltip.vue')), { + showing, + content, + targetElement: rootEl.value!, + }, { + closed: () => { + result.dispose(); + }, + }); +}); + +onMounted(() => { + bus.value.on('forceRefreshContentSize', onForceRefreshContentSize); +}); + +onUnmounted(() => { + bus.value.off('forceRefreshContentSize', onForceRefreshContentSize); +}); + +</script> + +<style module lang="scss"> +$cellHeight: 28px; + +.cell { + overflow: hidden; + white-space: nowrap; + height: $cellHeight; + max-height: $cellHeight; + min-height: $cellHeight; + cursor: cell; + + &:focus { + outline: none; + } +} + +.root { + display: flex; + flex-direction: row; + align-items: center; + box-sizing: border-box; + height: 100%; + + // selected適用時に中身がズレてしまうので、透明の線をあらかじめ引いておきたい + border: solid 0.5px transparent; + + &.selected { + border: solid 0.5px var(--MI_THEME-accentLighten); + } + + &.ranged { + background-color: var(--MI_THEME-accentedBg); + } + + &.center { + justify-content: center; + } + + &.error { + border: solid 0.5px var(--MI_THEME-error); + } +} + +.contentArea, .inputArea { + display: flex; + align-items: center; + width: 100%; + max-width: 100%; +} + +.content { + display: inline-block; + padding: 0 8px; +} + +.viewImage { + width: auto; + max-height: $cellHeight; + height: $cellHeight; + object-fit: cover; +} + +.bool { + position: relative; + width: 18px; + height: 18px; + background: var(--MI_THEME-panel); + border: solid 2px var(--MI_THEME-divider); + border-radius: 4px; + box-sizing: border-box; + + &.boolTrue { + border-color: var(--MI_THEME-accent); + background: var(--MI_THEME-accent); + + &::before { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--MI_THEME-fgOnAccent); + font-size: 12px; + line-height: 18px; + } + } +} + +.editingInput { + padding: 0 8px; + width: 100%; + max-width: 100%; + box-sizing: border-box; + min-height: $cellHeight - 2; + max-height: $cellHeight - 2; + height: $cellHeight - 2; + outline: none; + border: none; + font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; +} + +</style> diff --git a/packages/frontend/src/components/grid/MkDataRow.vue b/packages/frontend/src/components/grid/MkDataRow.vue new file mode 100644 index 0000000000..280a14bc4a --- /dev/null +++ b/packages/frontend/src/components/grid/MkDataRow.vue @@ -0,0 +1,72 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + class="mk_grid_tr" + :class="[ + $style.row, + row.ranged ? $style.ranged : {}, + ...(row.additionalStyles ?? []).map(it => it.className ?? {}), + ]" + :style="[ + ...(row.additionalStyles ?? []).map(it => it.style ?? {}), + ]" + :data-grid-row="row.index" +> + <MkNumberCell + v-if="setting.showNumber" + :content="(row.index + 1).toString()" + :row="row" + /> + <MkDataCell + v-for="cell in cells" + :key="cell.address.col" + :vIf="cell.column.setting.type !== 'hidden'" + :cell="cell" + :rowSetting="setting" + :bus="bus" + @operation:beginEdit="(sender) => emit('operation:beginEdit', sender)" + @operation:endEdit="(sender) => emit('operation:endEdit', sender)" + @change:value="(sender, newValue) => emit('change:value', sender, newValue)" + @change:contentSize="(sender, newSize) => emit('change:contentSize', sender, newSize)" + /> +</div> +</template> + +<script setup lang="ts"> +import { GridEventEmitter, Size } from '@/components/grid/grid.js'; +import MkDataCell from '@/components/grid/MkDataCell.vue'; +import MkNumberCell from '@/components/grid/MkNumberCell.vue'; +import { CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridRow, GridRowSetting } from '@/components/grid/row.js'; + +const emit = defineEmits<{ + (ev: 'operation:beginEdit', sender: GridCell): void; + (ev: 'operation:endEdit', sender: GridCell): void; + (ev: 'change:value', sender: GridCell, newValue: CellValue): void; + (ev: 'change:contentSize', sender: GridCell, newSize: Size): void; +}>(); +defineProps<{ + row: GridRow, + cells: GridCell[], + setting: GridRowSetting, + bus: GridEventEmitter, +}>(); + +</script> + +<style module lang="scss"> +.row { + display: flex; + flex-direction: row; + align-items: center; + width: fit-content; + + &.ranged { + background-color: var(--MI_THEME-accentedBg); + } +} +</style> diff --git a/packages/frontend/src/components/grid/MkGrid.stories.impl.ts b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts new file mode 100644 index 0000000000..5801012f15 --- /dev/null +++ b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts @@ -0,0 +1,223 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { ref } from 'vue'; +import { commonHandlers } from '../../../.storybook/mocks.js'; +import { boolean, choose, country, date, firstName, integer, lastName, text } from '../../../.storybook/fake-utils.js'; +import MkGrid from './MkGrid.vue'; +import { GridContext, GridEvent } from '@/components/grid/grid-event.js'; +import { DataSource, GridSetting } from '@/components/grid/grid.js'; +import { GridColumnSetting } from '@/components/grid/column.js'; + +function d(p: { + check?: boolean, + name?: string, + email?: string, + age?: number, + birthday?: string, + gender?: string, + country?: string, + reportCount?: number, + createdAt?: string, +}, seed: string) { + const prefix = text(10, seed); + + return { + check: p.check ?? boolean(seed), + name: p.name ?? `${firstName(seed)} ${lastName(seed)}`, + email: p.email ?? `${prefix}@example.com`, + age: p.age ?? integer(20, 80, seed), + birthday: date({}, seed).toISOString(), + gender: p.gender ?? choose(['male', 'female', 'other', 'unknown'], seed), + country: p.country ?? country(seed), + reportCount: p.reportCount ?? integer(0, 9999, seed), + createdAt: p.createdAt ?? date({}, seed).toISOString(), + }; +} + +const defaultCols: GridColumnSetting[] = [ + { bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50 }, + { bindTo: 'name', title: 'Name', type: 'text', width: 'auto' }, + { bindTo: 'email', title: 'Email', type: 'text', width: 'auto' }, + { bindTo: 'age', title: 'Age', type: 'number', width: 50 }, + { bindTo: 'birthday', title: 'Birthday', type: 'date', width: 'auto' }, + { bindTo: 'gender', title: 'Gender', type: 'text', width: 80 }, + { bindTo: 'country', title: 'Country', type: 'text', width: 120 }, + { bindTo: 'reportCount', title: 'ReportCount', type: 'number', width: 'auto' }, + { bindTo: 'createdAt', title: 'CreatedAt', type: 'date', width: 'auto' }, +]; + +function createArgs(overrides?: { settings?: Partial<GridSetting>, data?: DataSource[] }) { + const refData = ref<ReturnType<typeof d>[]>([]); + for (let i = 0; i < 100; i++) { + refData.value.push(d({}, i.toString())); + } + + return { + settings: { + row: overrides?.settings?.row, + cols: [ + ...defaultCols.filter(col => overrides?.settings?.cols?.every(c => c.bindTo !== col.bindTo) ?? true), + ...overrides?.settings?.cols ?? [], + ], + cells: overrides?.settings?.cells, + }, + data: refData.value, + }; +} + +function createRender(params: { settings: GridSetting, data: DataSource[] }) { + return { + render(args) { + return { + components: { + MkGrid, + }, + setup() { + return { + args, + }; + }, + data() { + return { + data: args.data, + }; + }, + computed: { + props() { + return { + ...args, + }; + }, + events() { + return { + event: (event: GridEvent, context: GridContext) => { + switch (event.type) { + case 'cell-value-change': { + args.data[event.row.index][event.column.setting.bindTo] = event.newValue; + } + } + }, + }; + }, + }, + template: '<div style="padding:20px"><MkGrid v-bind="props" v-on="events" /></div>', + }; + }, + args: { + ...params, + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + ], + }, + }, + } satisfies StoryObj<typeof MkGrid>; +} + +export const Default = createRender(createArgs()); + +export const NoNumber = createRender(createArgs({ + settings: { + row: { + showNumber: false, + }, + }, +})); + +export const NoSelectable = createRender(createArgs({ + settings: { + row: { + selectable: false, + }, + }, +})); + +export const Editable = createRender(createArgs({ + settings: { + cols: defaultCols.map(col => ({ ...col, editable: true })), + }, +})); + +export const AdditionalRowStyle = createRender(createArgs({ + settings: { + cols: defaultCols.map(col => ({ ...col, editable: true })), + row: { + styleRules: [ + { + condition: ({ row }) => AdditionalRowStyle.args.data[row.index].check as boolean, + applyStyle: { + style: { + backgroundColor: 'lightgray', + }, + }, + }, + ], + }, + }, +})); + +export const ContextMenu = createRender(createArgs({ + settings: { + cols: [ + { + bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50, contextMenuFactory: (col, context) => [ + { + type: 'button', + text: 'Check All', + action: () => { + for (const d of ContextMenu.args.data) { + d.check = true; + } + }, + }, + { + type: 'button', + text: 'Uncheck All', + action: () => { + for (const d of ContextMenu.args.data) { + d.check = false; + } + }, + }, + ], + }, + ], + row: { + contextMenuFactory: (row, context) => [ + { + type: 'button', + text: 'Delete', + action: () => { + const idxes = context.rangedRows.map(r => r.index); + const newData = ContextMenu.args.data.filter((d, i) => !idxes.includes(i)); + + ContextMenu.args.data.splice(0); + ContextMenu.args.data.push(...newData); + }, + }, + ], + }, + cells: { + contextMenuFactory: (col, row, value, context) => [ + { + type: 'button', + text: 'Delete', + action: () => { + for (const cell of context.rangedCells) { + ContextMenu.args.data[cell.row.index][cell.column.setting.bindTo] = undefined; + } + }, + }, + ], + }, + }, +})); diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue new file mode 100644 index 0000000000..4dbd4ebcae --- /dev/null +++ b/packages/frontend/src/components/grid/MkGrid.vue @@ -0,0 +1,1374 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + ref="rootEl" + class="mk_grid_border" + :class="[$style.grid, { + [$style.noOverflowHandling]: rootSetting.noOverflowStyle, + 'mk_grid_root_rounded': rootSetting.rounded, + 'mk_grid_root_border': rootSetting.outerBorder, + }]" + @mousedown.prevent="onMouseDown" + @keydown="onKeyDown" + @contextmenu.prevent.stop="onContextMenu" +> + <div class="mk_grid_thead"> + <MkHeaderRow + :columns="columns" + :gridSetting="rowSetting" + :bus="bus" + @operation:beginWidthChange="onHeaderCellWidthBeginChange" + @operation:endWidthChange="onHeaderCellWidthEndChange" + @operation:widthLargest="onHeaderCellWidthLargest" + @change:width="onHeaderCellChangeWidth" + @change:contentSize="onHeaderCellChangeContentSize" + /> + </div> + <div class="mk_grid_tbody"> + <MkDataRow + v-for="row in rows" + v-show="row.using" + :key="row.index" + :row="row" + :cells="cells[row.index].cells" + :setting="rowSetting" + :bus="bus" + :using="row.using" + :class="[lastLine === row.index ? 'last_row' : '']" + @operation:beginEdit="onCellEditBegin" + @operation:endEdit="onCellEditEnd" + @change:value="onChangeCellValue" + @change:contentSize="onChangeCellContentSize" + /> + </div> +</div> +</template> + +<script setup lang="ts"> +import { computed, onMounted, ref, toRefs, watch } from 'vue'; +import { DataSource, GridEventEmitter, GridSetting, GridState, Size } from '@/components/grid/grid.js'; +import MkDataRow from '@/components/grid/MkDataRow.vue'; +import MkHeaderRow from '@/components/grid/MkHeaderRow.vue'; +import { cellValidation } from '@/components/grid/cell-validators.js'; +import { CELL_ADDRESS_NONE, CellAddress, CellValue, createCell, GridCell, resetCell } from '@/components/grid/cell.js'; +import { + copyGridDataToClipboard, + equalCellAddress, + getCellAddress, + getCellElement, + pasteToGridFromClipboard, + removeDataFromGrid, +} from '@/components/grid/grid-utils.js'; +import { MenuItem } from '@/types/menu.js'; +import * as os from '@/os.js'; +import { GridContext, GridEvent } from '@/components/grid/grid-event.js'; +import { createColumn, GridColumn } from '@/components/grid/column.js'; +import { createRow, defaultGridRowSetting, GridRow, GridRowSetting, resetRow } from '@/components/grid/row.js'; +import { handleKeyEvent } from '@/scripts/key-event.js'; + +type RowHolder = { + row: GridRow, + cells: GridCell[], + origin: DataSource, +} + +const emit = defineEmits<{ + (ev: 'event', event: GridEvent, context: GridContext): void; +}>(); + +const props = defineProps<{ + settings: GridSetting; + data: DataSource[]; +}>(); + +const rootSetting: Required<GridSetting['root']> = { + noOverflowStyle: false, + rounded: true, + outerBorder: true, + ...props.settings.root, +}; + +// non-reactive +// eslint-disable-next-line vue/no-setup-props-reactivity-loss +const rowSetting: Required<GridRowSetting> = { + ...defaultGridRowSetting, + ...props.settings.row, +}; + +// non-reactive +// eslint-disable-next-line vue/no-setup-props-reactivity-loss +const columnSettings = props.settings.cols; + +// non-reactive +const cellSettings = props.settings.cells ?? {}; + +const { data } = toRefs(props); + +// #region Event Definitions +// region Event Definitions + +/** + * grid -> 各子コンポーネントのイベント経路を担う{@link GridEventEmitter}。おもにpropsでの伝搬が難しいイベントを伝搬するために使用する。 + * 子コンポーネント -> gridのイベントでは原則使用せず、{@link emit}を使用する。 + */ +const bus = new GridEventEmitter(); +/** + * テーブルコンポーネントのリサイズイベントを監視するための{@link ResizeObserver}。 + * 表示切替を検知し、サイズの再計算要求を発行するために使用する(マウント時にコンテンツが表示されていない場合、初手のサイズの自動計算が正常に働かないため) + * + * {@link setTimeout}を経由している理由は、{@link onResize}の中でサイズ再計算要求→サイズ変更が発生するとループとみなされ、 + * 「ResizeObserver loop completed with undelivered notifications.」という警告が発生するため(再計算が完全に終われば通知は発生しなくなるので実際にはループしない) + * + * @see {@link onResize} + */ +const resizeObserver = new ResizeObserver((entries) => setTimeout(() => onResize(entries))); + +const rootEl = ref<InstanceType<typeof HTMLTableElement>>(); +/** + * グリッドの最も上位にある状態。 + */ +const state = ref<GridState>('normal'); +/** + * グリッドの列定義。列定義の元の設定値は非リアクティブなので、初期値を生成して以降は変更しない。 + */ +const columns = ref<GridColumn[]>(columnSettings.map(createColumn)); +/** + * グリッドの行定義。propsで受け取った{@link data}をもとに、{@link refreshData}で再計算される。 + */ +const rows = ref<GridRow[]>([]); +/** + * グリッドのセル定義。propsで受け取った{@link data}をもとに、{@link refreshData}で再計算される。 + */ +const cells = ref<RowHolder[]>([]); + +/** + * mousemoveイベントが発生した際に、イベントから取得したセルアドレスを保持するための変数。 + * セルアドレスが変わった瞬間にイベントを起こしたい時のために前回値として使用する。 + */ +const previousCellAddress = ref<CellAddress>(CELL_ADDRESS_NONE); +/** + * 編集中のセルのアドレスを保持するための変数。 + */ +const editingCellAddress = ref<CellAddress>(CELL_ADDRESS_NONE); +/** + * 列の範囲選択をする際の開始地点となるインデックスを保持するための変数。 + * この開始地点からマウスが動いた地点までの範囲を選択する。 + */ +const firstSelectionColumnIdx = ref<number>(CELL_ADDRESS_NONE.col); +/** + * 行の範囲選択をする際の開始地点となるインデックスを保持するための変数。 + * この開始地点からマウスが動いた地点までの範囲を選択する。 + */ +const firstSelectionRowIdx = ref<number>(CELL_ADDRESS_NONE.row); + +/** + * 選択状態のセルを取得するための計算プロパティ。選択状態とは{@link GridCell.selected}がtrueのセルのこと。 + */ +const selectedCell = computed(() => { + const selected = cells.value.flatMap(it => it.cells).filter(it => it.selected); + return selected.length > 0 ? selected[0] : undefined; +}); +/** + * 範囲選択状態のセルを取得するための計算プロパティ。範囲選択状態とは{@link GridCell.ranged}がtrueのセルのこと。 + */ +const rangedCells = computed(() => cells.value.flatMap(it => it.cells).filter(it => it.ranged)); +/** + * 範囲選択状態のセルの範囲を取得するための計算プロパティ。左上のセル番地と右下のセル番地を計算する。 + */ +const rangedBounds = computed(() => { + const _cells = rangedCells.value; + const _cols = _cells.map(it => it.address.col); + const _rows = _cells.map(it => it.address.row); + + const leftTop = { + col: Math.min(..._cols), + row: Math.min(..._rows), + }; + const rightBottom = { + col: Math.max(..._cols), + row: Math.max(..._rows), + }; + + return { + leftTop, + rightBottom, + }; +}); +/** + * グリッドの中で使用可能なセルの範囲を取得するための計算プロパティ。左上のセル番地と右下のセル番地を計算する。 + */ +const availableBounds = computed(() => { + const leftTop = { + col: 0, + row: 0, + }; + const rightBottom = { + col: Math.max(...columns.value.map(it => it.index)), + row: Math.max(...rows.value.filter(it => it.using).map(it => it.index)), + }; + return { leftTop, rightBottom }; +}); +/** + * 範囲選択状態の行を取得するための計算プロパティ。範囲選択状態とは{@link GridRow.ranged}がtrueの行のこと。 + */ +const rangedRows = computed(() => rows.value.filter(it => it.ranged)); + +const lastLine = computed(() => rows.value.filter(it => it.using).length - 1); + +// endregion +// #endregion + +watch(data, patchData, { deep: true }); + +if (_DEV_) { + watch(state, (value, oldValue) => { + console.log(`[grid][state] ${oldValue} -> ${value}`); + }); +} + +// #region Event Handlers +// region Event Handlers + +function onResize(entries: ResizeObserverEntry[]) { + if (entries.length !== 1 || entries[0].target !== rootEl.value) { + return; + } + + const contentRect = entries[0].contentRect; + if (_DEV_) { + console.log(`[grid][resize] contentRect: ${contentRect.width}x${contentRect.height}`); + } + + switch (state.value) { + case 'hidden': { + if (contentRect.width > 0 && contentRect.height > 0) { + // 先に状態を変更しておき、再計算要求が複数回走らないようにする + state.value = 'normal'; + + // 選択状態が狂うかもしれないので解除しておく + unSelectionRangeAll(); + + // 再計算要求を発行。各セル側で最低限必要な横幅を算出し、emitで返してくるようになっている + bus.emit('forceRefreshContentSize'); + } + break; + } + default: { + if (contentRect.width === 0 || contentRect.height === 0) { + state.value = 'hidden'; + } + break; + } + } +} + +function onKeyDown(ev: KeyboardEvent) { + const { ctrlKey, shiftKey, code } = ev; + if (_DEV_) { + console.log(`[grid][key] ctrl: ${ctrlKey}, shift: ${shiftKey}, code: ${code}`); + } + + function updateSelectionRange(newBounds: { leftTop: CellAddress, rightBottom: CellAddress }) { + unSelectionOutOfRange(newBounds.leftTop, newBounds.rightBottom); + expandCellRange(newBounds.leftTop, newBounds.rightBottom); + } + + switch (state.value) { + case 'normal': { + ev.preventDefault(); + ev.stopPropagation(); + + const selectedCellAddress = selectedCell.value?.address ?? CELL_ADDRESS_NONE; + const max = availableBounds.value; + const bounds = rangedBounds.value; + + handleKeyEvent(ev, [ + { + code: 'Delete', handler: () => { + if (rangedRows.value.length > 0) { + if (rowSetting.events.delete) { + rowSetting.events.delete(rangedRows.value); + } + } else { + const context = createContext(); + removeDataFromGrid(context, (cell) => { + emitCellValue(cell, undefined); + }); + } + }, + }, + { + code: 'KeyC', modifiers: ['Control'], handler: () => { + const context = createContext(); + copyGridDataToClipboard(data.value, context); + }, + }, + { + code: 'KeyV', modifiers: ['Control'], handler: async () => { + const _cells = cells.value; + const context = createContext(); + await pasteToGridFromClipboard(context, (row, col, parsedValue) => { + emitCellValue(_cells[row.index].cells[col.index], parsedValue); + }); + }, + }, + { + code: 'ArrowRight', modifiers: ['Control', 'Shift'], handler: () => { + updateSelectionRange({ + leftTop: { col: selectedCellAddress.col, row: bounds.leftTop.row }, + rightBottom: { col: max.rightBottom.col, row: bounds.rightBottom.row }, + }); + }, + }, + { + code: 'ArrowLeft', modifiers: ['Control', 'Shift'], handler: () => { + updateSelectionRange({ + leftTop: { col: max.leftTop.col, row: bounds.leftTop.row }, + rightBottom: { col: selectedCellAddress.col, row: bounds.rightBottom.row }, + }); + }, + }, + { + code: 'ArrowUp', modifiers: ['Control', 'Shift'], handler: () => { + updateSelectionRange({ + leftTop: { col: bounds.leftTop.col, row: max.leftTop.row }, + rightBottom: { col: bounds.rightBottom.col, row: selectedCellAddress.row }, + }); + }, + }, + { + code: 'ArrowDown', modifiers: ['Control', 'Shift'], handler: () => { + updateSelectionRange({ + leftTop: { col: bounds.leftTop.col, row: selectedCellAddress.row }, + rightBottom: { col: bounds.rightBottom.col, row: max.rightBottom.row }, + }); + }, + }, + { + code: 'ArrowRight', modifiers: ['Shift'], handler: () => { + updateSelectionRange({ + leftTop: { + col: bounds.leftTop.col < selectedCellAddress.col + ? bounds.leftTop.col + 1 + : selectedCellAddress.col, + row: bounds.leftTop.row, + }, + rightBottom: { + col: (bounds.rightBottom.col > selectedCellAddress.col || bounds.leftTop.col === selectedCellAddress.col) + ? bounds.rightBottom.col + 1 + : selectedCellAddress.col, + row: bounds.rightBottom.row, + }, + }); + }, + }, + { + code: 'ArrowLeft', modifiers: ['Shift'], handler: () => { + updateSelectionRange({ + leftTop: { + col: (bounds.leftTop.col < selectedCellAddress.col || bounds.rightBottom.col === selectedCellAddress.col) + ? bounds.leftTop.col - 1 + : selectedCellAddress.col, + row: bounds.leftTop.row, + }, + rightBottom: { + col: bounds.rightBottom.col > selectedCellAddress.col + ? bounds.rightBottom.col - 1 + : selectedCellAddress.col, + row: bounds.rightBottom.row, + }, + }); + }, + }, + { + code: 'ArrowUp', modifiers: ['Shift'], handler: () => { + updateSelectionRange({ + leftTop: { + col: bounds.leftTop.col, + row: (bounds.leftTop.row < selectedCellAddress.row || bounds.rightBottom.row === selectedCellAddress.row) + ? bounds.leftTop.row - 1 + : selectedCellAddress.row, + }, + rightBottom: { + col: bounds.rightBottom.col, + row: bounds.rightBottom.row > selectedCellAddress.row + ? bounds.rightBottom.row - 1 + : selectedCellAddress.row, + }, + }); + }, + }, + { + code: 'ArrowDown', modifiers: ['Shift'], handler: () => { + updateSelectionRange({ + leftTop: { + col: bounds.leftTop.col, + row: bounds.leftTop.row < selectedCellAddress.row + ? bounds.leftTop.row + 1 + : selectedCellAddress.row, + }, + rightBottom: { + col: bounds.rightBottom.col, + row: (bounds.rightBottom.row > selectedCellAddress.row || bounds.leftTop.row === selectedCellAddress.row) + ? bounds.rightBottom.row + 1 + : selectedCellAddress.row, + }, + }); + }, + }, + { + code: 'ArrowDown', handler: () => { + selectionCell({ col: selectedCellAddress.col, row: selectedCellAddress.row + 1 }); + }, + }, + { + code: 'ArrowUp', handler: () => { + selectionCell({ col: selectedCellAddress.col, row: selectedCellAddress.row - 1 }); + }, + }, + { + code: 'ArrowRight', handler: () => { + selectionCell({ col: selectedCellAddress.col + 1, row: selectedCellAddress.row }); + }, + }, + { + code: 'ArrowLeft', handler: () => { + selectionCell({ col: selectedCellAddress.col - 1, row: selectedCellAddress.row }); + }, + }, + ]); + + break; + } + } +} + +function onMouseDown(ev: MouseEvent) { + switch (ev.button) { + case 0: { + onLeftMouseDown(ev); + break; + } + case 2: { + onRightMouseDown(ev); + break; + } + } +} + +function onLeftMouseDown(ev: MouseEvent) { + const cellAddress = getCellAddress(ev.target as HTMLElement); + if (_DEV_) { + console.log(`[grid][mouse-left] state:${state.value}, button: ${ev.button}, cell: ${cellAddress.row}x${cellAddress.col}`); + } + + switch (state.value) { + case 'cellEditing': { + if (availableCellAddress(cellAddress) && !equalCellAddress(editingCellAddress.value, cellAddress)) { + selectionCell(cellAddress); + } + break; + } + case 'normal': { + if (availableCellAddress(cellAddress)) { + if (ev.shiftKey && selectedCell.value && !equalCellAddress(cellAddress, selectedCell.value.address)) { + const selectedCellAddress = selectedCell.value.address; + + const leftTop = { + col: Math.min(selectedCellAddress.col, cellAddress.col), + row: Math.min(selectedCellAddress.row, cellAddress.row), + }; + + const rightBottom = { + col: Math.max(selectedCellAddress.col, cellAddress.col), + row: Math.max(selectedCellAddress.row, cellAddress.row), + }; + + unSelectionRangeAll(); + expandCellRange(leftTop, rightBottom); + + cells.value[selectedCellAddress.row].cells[selectedCellAddress.col].selected = true; + } else { + selectionCell(cellAddress); + } + + previousCellAddress.value = cellAddress; + + registerMouseUp(); + registerMouseMove(); + state.value = 'cellSelecting'; + } else if (isColumnHeaderCellAddress(cellAddress)) { + if (ev.shiftKey) { + const rangedColumnIndexes = rangedCells.value.map(it => it.address.col); + const targetColumnIndexes = [cellAddress.col, ...rangedColumnIndexes]; + unSelectionRangeAll(); + + const leftTop = { + col: Math.min(...targetColumnIndexes), + row: 0, + }; + + const rightBottom = { + col: Math.max(...targetColumnIndexes), + row: cells.value.length - 1, + }; + + expandCellRange(leftTop, rightBottom); + + if (rangedColumnIndexes.length === 0) { + firstSelectionColumnIdx.value = cellAddress.col; + } else { + if (cellAddress.col > Math.min(...rangedColumnIndexes)) { + firstSelectionColumnIdx.value = Math.min(...rangedColumnIndexes); + } else { + firstSelectionColumnIdx.value = Math.max(...rangedColumnIndexes); + } + } + } else { + unSelectionRangeAll(); + + const colCells = cells.value.map(row => row.cells[cellAddress.col]); + selectionRange(...colCells.map(cell => cell.address)); + + firstSelectionColumnIdx.value = cellAddress.col; + } + + registerMouseUp(); + registerMouseMove(); + previousCellAddress.value = cellAddress; + state.value = 'colSelecting'; + + // フォーカスを当てないとキーイベントが拾えないので + getCellElement(ev.target as HTMLElement)?.focus(); + } else if (isRowNumberCellAddress(cellAddress)) { + if (ev.shiftKey) { + const rangedRowIndexes = rangedRows.value.map(it => it.index); + const targetRowIndexes = [cellAddress.row, ...rangedRowIndexes]; + unSelectionRangeAll(); + + const leftTop = { + col: 0, + row: Math.min(...targetRowIndexes), + }; + + const rightBottom = { + col: Math.min(...cells.value.map(it => it.cells.length - 1)), + row: Math.max(...targetRowIndexes), + }; + + expandCellRange(leftTop, rightBottom); + expandRowRange(Math.min(...targetRowIndexes), Math.max(...targetRowIndexes)); + + if (rangedRowIndexes.length === 0) { + firstSelectionRowIdx.value = cellAddress.row; + } else { + if (cellAddress.col > Math.min(...rangedRowIndexes)) { + firstSelectionRowIdx.value = Math.min(...rangedRowIndexes); + } else { + firstSelectionRowIdx.value = Math.max(...rangedRowIndexes); + } + } + } else { + unSelectionRangeAll(); + const rowCells = cells.value[cellAddress.row].cells; + selectionRange(...rowCells.map(cell => cell.address)); + expandRowRange(cellAddress.row, cellAddress.row); + + firstSelectionRowIdx.value = cellAddress.row; + } + + registerMouseUp(); + registerMouseMove(); + previousCellAddress.value = cellAddress; + state.value = 'rowSelecting'; + + // フォーカスを当てないとキーイベントが拾えないので + getCellElement(ev.target as HTMLElement)?.focus(); + } + break; + } + } +} + +function onRightMouseDown(ev: MouseEvent) { + const cellAddress = getCellAddress(ev.target as HTMLElement); + if (_DEV_) { + console.log(`[grid][mouse-right] button: ${ev.button}, cell: ${cellAddress.row}x${cellAddress.col}`); + } + + switch (state.value) { + case 'normal': { + if (!availableCellAddress(cellAddress)) { + return; + } + + const _rangedCells = [...rangedCells.value]; + if (!_rangedCells.some(it => equalCellAddress(it.address, cellAddress))) { + // 範囲選択外を右クリックした場合は、範囲選択を解除(範囲選択内であれば範囲選択を維持する) + selectionCell(cellAddress); + } + + break; + } + } +} + +function onMouseMove(ev: MouseEvent) { + ev.preventDefault(); + + const targetCellAddress = getCellAddress(ev.target as HTMLElement); + if (equalCellAddress(previousCellAddress.value, targetCellAddress)) { + // セルが変わるまでイベントを起こしたくない + return; + } + + if (_DEV_) { + console.log(`[grid][mouse-move] button: ${ev.button}, cell: ${targetCellAddress.row}x${targetCellAddress.col}`); + } + + switch (state.value) { + case 'cellSelecting': { + const selectedCellAddress = selectedCell.value?.address; + if (!availableCellAddress(targetCellAddress) || !selectedCellAddress) { + // 正しいセル範囲ではない + return; + } + + const leftTop = { + col: Math.min(targetCellAddress.col, selectedCellAddress.col), + row: Math.min(targetCellAddress.row, selectedCellAddress.row), + }; + + const rightBottom = { + col: Math.max(targetCellAddress.col, selectedCellAddress.col), + row: Math.max(targetCellAddress.row, selectedCellAddress.row), + }; + + // 範囲外のセルは選択解除し、範囲内のセルは選択状態にする + unSelectionOutOfRange(leftTop, rightBottom); + expandCellRange(leftTop, rightBottom); + previousCellAddress.value = targetCellAddress; + + break; + } + case 'colSelecting': { + if (!isColumnHeaderCellAddress(targetCellAddress) || previousCellAddress.value.col === targetCellAddress.col) { + // セルが変わるまでイベントを起こしたくない + return; + } + + const leftTop = { + col: Math.min(targetCellAddress.col, firstSelectionColumnIdx.value), + row: 0, + }; + + const rightBottom = { + col: Math.max(targetCellAddress.col, firstSelectionColumnIdx.value), + row: cells.value.length - 1, + }; + + // 範囲外のセルは選択解除し、範囲内のセルは選択状態にする + unSelectionOutOfRange(leftTop, rightBottom); + expandCellRange(leftTop, rightBottom); + previousCellAddress.value = targetCellAddress; + + // フォーカスを当てないとキーイベントが拾えないので + getCellElement(ev.target as HTMLElement)?.focus(); + + break; + } + case 'rowSelecting': { + if (!isRowNumberCellAddress(targetCellAddress) || previousCellAddress.value.row === targetCellAddress.row) { + // セルが変わるまでイベントを起こしたくない + return; + } + + const leftTop = { + col: 0, + row: Math.min(targetCellAddress.row, firstSelectionRowIdx.value), + }; + + const rightBottom = { + col: Math.min(...cells.value.map(it => it.cells.length - 1)), + row: Math.max(targetCellAddress.row, firstSelectionRowIdx.value), + }; + + // 範囲外のセルは選択解除し、範囲内のセルは選択状態にする + unSelectionOutOfRange(leftTop, rightBottom); + expandCellRange(leftTop, rightBottom); + + // 行も同様に + const rangedRowIndexes = [rows.value[targetCellAddress.row].index, ...rangedRows.value.map(it => it.index)]; + expandRowRange(Math.min(...rangedRowIndexes), Math.max(...rangedRowIndexes)); + + previousCellAddress.value = targetCellAddress; + + // フォーカスを当てないとキーイベントが拾えないので + getCellElement(ev.target as HTMLElement)?.focus(); + + break; + } + } +} + +function onMouseUp(ev: MouseEvent) { + ev.preventDefault(); + switch (state.value) { + case 'rowSelecting': + case 'colSelecting': + case 'cellSelecting': { + unregisterMouseUp(); + unregisterMouseMove(); + state.value = 'normal'; + previousCellAddress.value = CELL_ADDRESS_NONE; + break; + } + } +} + +function onContextMenu(ev: MouseEvent) { + const cellAddress = getCellAddress(ev.target as HTMLElement); + if (_DEV_) { + console.log(`[grid][context-menu] button: ${ev.button}, cell: ${cellAddress.row}x${cellAddress.col}`); + } + + const context = createContext(); + const menuItems = Array.of<MenuItem>(); + switch (true) { + // 通常セルのコンテキストメニュー作成 + case availableCellAddress(cellAddress): { + const cell = cells.value[cellAddress.row].cells[cellAddress.col]; + if (cell.setting.contextMenuFactory) { + menuItems.push(...cell.setting.contextMenuFactory(cell.column, cell.row, cell.value, context)); + } + break; + } + // 列ヘッダセルのコンテキストメニュー作成 + case isColumnHeaderCellAddress(cellAddress): { + const col = columns.value[cellAddress.col]; + if (col.setting.contextMenuFactory) { + menuItems.push(...col.setting.contextMenuFactory(col, context)); + } + break; + } + // 行ヘッダセルのコンテキストメニュー作成 + case isRowNumberCellAddress(cellAddress): { + const row = rows.value[cellAddress.row]; + if (row.setting.contextMenuFactory) { + menuItems.push(...row.setting.contextMenuFactory(row, context)); + } + break; + } + } + + if (menuItems.length > 0) { + os.contextMenu(menuItems, ev); + } +} + +function onCellEditBegin(sender: GridCell) { + state.value = 'cellEditing'; + editingCellAddress.value = sender.address; + for (const cell of cells.value.flatMap(it => it.cells)) { + if (cell.address.col !== sender.address.col || cell.address.row !== sender.address.row) { + // 編集状態となったセル以外は全部選択解除 + cell.selected = false; + } + } +} + +function onCellEditEnd() { + editingCellAddress.value = CELL_ADDRESS_NONE; + state.value = 'normal'; +} + +function onChangeCellValue(sender: GridCell, newValue: CellValue) { + applyRowRules([sender]); + emitCellValue(sender, newValue); +} + +function onChangeCellContentSize(sender: GridCell, contentSize: Size) { + const _cells = cells.value; + if (_cells.length > sender.address.row && _cells[sender.address.row].cells.length > sender.address.col) { + const currentSize = _cells[sender.address.row].cells[sender.address.col].contentSize; + if (currentSize.width !== contentSize.width || currentSize.height !== contentSize.height) { + // 通常セルのセル幅が確定したら、そのサイズを保持しておく(内容に引っ張られて想定よりも大きいセルサイズにならないようにするためのCSS作成に使用) + _cells[sender.address.row].cells[sender.address.col].contentSize = contentSize; + + if (sender.column.setting.width === 'auto') { + calcLargestCellWidth(sender.column); + } + } + } +} + +function onHeaderCellWidthBeginChange() { + switch (state.value) { + case 'normal': { + state.value = 'colResizing'; + break; + } + } +} + +function onHeaderCellWidthEndChange() { + switch (state.value) { + case 'colResizing': { + state.value = 'normal'; + break; + } + } +} + +function onHeaderCellChangeWidth(sender: GridColumn, width: string) { + switch (state.value) { + case 'colResizing': { + const column = columns.value[sender.index]; + column.width = width; + break; + } + } +} + +function onHeaderCellChangeContentSize(sender: GridColumn, newSize: Size) { + switch (state.value) { + case 'normal': { + const currentSize = columns.value[sender.index].contentSize; + if (currentSize.width !== newSize.width || currentSize.height !== newSize.height) { + // ヘッダセルのセル幅が確定したら、そのサイズを保持しておく(内容に引っ張られて想定よりも大きいセルサイズにならないようにするためのCSS作成に使用) + columns.value[sender.index].contentSize = newSize; + + if (sender.setting.width === 'auto') { + calcLargestCellWidth(sender); + } + } + break; + } + } +} + +function onHeaderCellWidthLargest(sender: GridColumn) { + switch (state.value) { + case 'normal': { + calcLargestCellWidth(sender); + break; + } + } +} + +// endregion +// #endregion + +// #region Methods +// region Methods + +/** + * カラム内のコンテンツを表示しきるために必要な横幅と、各セルのコンテンツを表示しきるために必要な横幅を比較し、大きい方を列全体の横幅として採用する。 + */ +function calcLargestCellWidth(column: GridColumn) { + const _cells = cells.value; + const largestColumnWidth = columns.value[column.index].contentSize.width; + + const largestCellWidth = (_cells.length > 0) + ? _cells + .map(row => row.cells[column.index]) + .reduce( + (acc, value) => Math.max(acc, value.contentSize.width), + 0, + ) + : 0; + + if (_DEV_) { + console.log(`[grid][calc-largest] idx:${column.setting.bindTo}, col:${largestColumnWidth}, cell:${largestCellWidth}`); + } + + column.width = `${Math.max(largestColumnWidth, largestCellWidth)}px`; +} + +/** + * {@link emit}を使用してイベントを発行する。 + */ +function emitGridEvent(ev: GridEvent) { + const currentState: GridContext = { + selectedCell: selectedCell.value, + rangedCells: rangedCells.value, + rangedRows: rangedRows.value, + randedBounds: rangedBounds.value, + availableBounds: availableBounds.value, + state: state.value, + rows: rows.value, + columns: columns.value, + }; + + emit( + 'event', + ev, + currentState, + ); +} + +/** + * 親コンポーネントに新しい値を通知する。 + * 新しい値は、イベント通知→元データへの反映→再計算(バリデーション含む)→再描画の流れで反映される。 + */ +function emitCellValue(sender: GridCell | CellAddress, newValue: CellValue) { + const cellAddress = 'address' in sender ? sender.address : sender; + const cell = cells.value[cellAddress.row].cells[cellAddress.col]; + + emitGridEvent({ + type: 'cell-value-change', + column: cell.column, + row: cell.row, + oldValue: cell.value, + newValue: newValue, + }); + + if (_DEV_) { + console.log(`[grid][cell-value] row:${cell.row}, col:${cell.column.index}, value:${newValue}`); + } +} + +/** + * {@link target}のセルを選択状態にする。 + * その際、{@link target}以外の行およびセルの範囲選択状態を解除する。 + */ +function selectionCell(target: CellAddress) { + if (!availableCellAddress(target)) { + return; + } + + unSelectionRangeAll(); + + const _cells = cells.value; + _cells[target.row].cells[target.col].selected = true; + _cells[target.row].cells[target.col].ranged = true; +} + +/** + * {@link targets}のセルを範囲選択状態にする。 + */ +function selectionRange(...targets: CellAddress[]) { + const _cells = cells.value; + for (const target of targets) { + const row = _cells[target.row]; + if (row.row.using) { + row.cells[target.col].ranged = true; + } + } +} + +/** + * 行およびセルの範囲選択状態をすべて解除する。 + */ +function unSelectionRangeAll() { + const _cells = rangedCells.value; + for (const cell of _cells) { + cell.selected = false; + cell.ranged = false; + } + + const _rows = rows.value.filter(it => it.using); + for (const row of _rows) { + row.ranged = false; + } +} + +/** + * {@link leftTop}から{@link rightBottom}の範囲外にあるセルを範囲選択状態から外す。 + */ +function unSelectionOutOfRange(leftTop: CellAddress, rightBottom: CellAddress) { + const safeBounds = getSafeAddressBounds({ leftTop, rightBottom }); + + const _cells = rangedCells.value; + for (const cell of _cells) { + const outOfRangeCol = cell.address.col < safeBounds.leftTop.col || cell.address.col > safeBounds.rightBottom.col; + const outOfRangeRow = cell.address.row < safeBounds.leftTop.row || cell.address.row > safeBounds.rightBottom.row; + if (outOfRangeCol || outOfRangeRow) { + cell.ranged = false; + } + } + + const outOfRangeRows = rows.value.filter((_, index) => index < safeBounds.leftTop.row || index > safeBounds.rightBottom.row); + for (const row of outOfRangeRows) { + row.ranged = false; + } +} + +/** + * {@link leftTop}から{@link rightBottom}の範囲内にあるセルを範囲選択状態にする。 + */ +function expandCellRange(leftTop: CellAddress, rightBottom: CellAddress) { + const safeBounds = getSafeAddressBounds({ leftTop, rightBottom }); + const targetRows = cells.value.slice(safeBounds.leftTop.row, safeBounds.rightBottom.row + 1); + for (const row of targetRows) { + for (const cell of row.cells.slice(safeBounds.leftTop.col, safeBounds.rightBottom.col + 1)) { + cell.ranged = true; + } + } +} + +/** + * {@link top}から{@link bottom}までの行を範囲選択状態にする。 + */ +function expandRowRange(top: number, bottom: number) { + if (!rowSetting.selectable) { + return; + } + + const targetRows = rows.value.slice(top, bottom + 1); + for (const row of targetRows) { + row.ranged = true; + } +} + +/** + * 特定の条件下でのみ適用されるCSSを反映する。 + */ +function applyRowRules(targetCells: GridCell[]) { + const _rows = rows.value; + const targetRowIdxes = [...new Set(targetCells.map(it => it.address.row))]; + const rowGroups = Array.of<{ row: GridRow, cells: GridCell[] }>(); + for (const rowIdx of targetRowIdxes) { + const rowGroup = targetCells.filter(it => it.address.row === rowIdx); + rowGroups.push({ row: _rows[rowIdx], cells: rowGroup }); + } + + const _cells = cells.value; + for (const group of rowGroups.filter(it => it.row.using)) { + const row = group.row; + const targetCols = group.cells.map(it => it.column); + const rowCells = _cells[group.row.index].cells; + + const newStyles = rowSetting.styleRules + .filter(it => it.condition({ row, targetCols, cells: rowCells })) + .map(it => it.applyStyle); + + if (JSON.stringify(newStyles) !== JSON.stringify(row.additionalStyles)) { + row.additionalStyles = newStyles; + } + } +} + +function availableCellAddress(cellAddress: CellAddress): boolean { + const safeBounds = availableBounds.value; + return cellAddress.row >= safeBounds.leftTop.row && + cellAddress.col >= safeBounds.leftTop.col && + cellAddress.row <= safeBounds.rightBottom.row && + cellAddress.col <= safeBounds.rightBottom.col; +} + +function isColumnHeaderCellAddress(cellAddress: CellAddress): boolean { + return cellAddress.row === -1 && cellAddress.col >= 0; +} + +function isRowNumberCellAddress(cellAddress: CellAddress): boolean { + return cellAddress.row >= 0 && cellAddress.col === -1; +} + +function getSafeAddressBounds( + bounds: { leftTop: CellAddress, rightBottom: CellAddress }, +): { leftTop: CellAddress, rightBottom: CellAddress } { + const available = availableBounds.value; + + const safeLeftTop = { + col: Math.max(bounds.leftTop.col, available.leftTop.col), + row: Math.max(bounds.leftTop.row, available.leftTop.row), + }; + const safeRightBottom = { + col: Math.min(bounds.rightBottom.col, available.rightBottom.col), + row: Math.min(bounds.rightBottom.row, available.rightBottom.row), + }; + + return { leftTop: safeLeftTop, rightBottom: safeRightBottom }; +} + +function registerMouseMove() { + unregisterMouseMove(); + addEventListener('mousemove', onMouseMove); +} + +function unregisterMouseMove() { + removeEventListener('mousemove', onMouseMove); +} + +function registerMouseUp() { + unregisterMouseUp(); + addEventListener('mouseup', onMouseUp); +} + +function unregisterMouseUp() { + removeEventListener('mouseup', onMouseUp); +} + +function createContext(): GridContext { + return { + selectedCell: selectedCell.value, + rangedCells: rangedCells.value, + rangedRows: rangedRows.value, + randedBounds: rangedBounds.value, + availableBounds: availableBounds.value, + state: state.value, + rows: rows.value, + columns: columns.value, + }; +} + +function refreshData() { + if (_DEV_) { + console.log('[grid][refresh-data][begin]'); + } + + // データを元に行・列・セルを作成する。 + // 行は元データの配列の長さに応じて作成するが、最低限の行数は設定によって決まる。 + // 行数が変わるたびに都度レンダリングするとパフォーマンスがイマイチなので、あらかじめ多めにセルを用意しておくための措置。 + const _data: DataSource[] = data.value; + const _rows: GridRow[] = (_data.length > rowSetting.minimumDefinitionCount) + ? _data.map((_, index) => createRow(index, true, rowSetting)) + : Array.from({ length: rowSetting.minimumDefinitionCount }, (_, index) => createRow(index, index < _data.length, rowSetting)); + const _cols: GridColumn[] = columns.value; + + // 行・列の定義から、元データの配列より値を取得してセルを作成する。 + // 行・列の定義はそれぞれインデックスを持っており、そのインデックスは元データの配列番地に対応している。 + const _cells: RowHolder[] = _rows.map(row => { + const newCells = row.using + ? _cols.map(col => createCell(col, row, _data[row.index][col.setting.bindTo], cellSettings)) + : _cols.map(col => createCell(col, row, undefined, cellSettings)); + + return { row, cells: newCells, origin: _data[row.index] }; + }); + + rows.value = _rows; + cells.value = _cells; + + const allCells = _cells.filter(it => it.row.using).flatMap(it => it.cells); + for (const cell of allCells) { + cell.violation = cellValidation(allCells, cell, cell.value); + } + + applyRowRules(allCells); + + if (_DEV_) { + console.log('[grid][refresh-data][end]'); + } +} + +/** + * セル値を部分更新する。この関数は、外部起因でデータが変更された場合に呼ばれる。 + * + * 外部起因でデータが変更された場合は{@link data}の値が変更されるが、何処の番地がどのように変わったのかまでは検知できない。 + * セルをすべて作り直せばいいが、その手法だと以下のデメリットがある。 + * - 描画負荷がかかる + * - 各セルが持つ個別の状態(選択中状態やバリデーション結果など)が失われる + * + * そこで、新しい値とセルが持つ値を突き合わせ、変更があった場合のみ値を更新し、セルそのものは使いまわしつつ値を最新化する。 + */ +function patchData(newItems: DataSource[]) { + if (_DEV_) { + console.log('[grid][patch-data][begin]'); + } + + const _cols = columns.value; + + if (rows.value.length < newItems.length) { + const newRows = Array.of<GridRow>(); + const newCells = Array.of<RowHolder>(); + + // 未使用の行を含めても足りないので新しい行を追加する + for (let rowIdx = rows.value.length; rowIdx < newItems.length; rowIdx++) { + const newRow = createRow(rowIdx, true, rowSetting); + newRows.push(newRow); + newCells.push({ + row: newRow, + cells: _cols.map(col => createCell(col, newRow, newItems[rowIdx][col.setting.bindTo], cellSettings)), + origin: newItems[rowIdx], + }); + } + + rows.value.push(...newRows); + cells.value.push(...newCells); + + applyRowRules(newCells.flatMap(it => it.cells)); + } + + // 行数の上限が欲しい場合はここに設けてもいいかもしれない + + const usingRows = rows.value.filter(it => it.using); + if (usingRows.length > newItems.length) { + // 行数が減っているので古い行をクリアする(再マウント・再レンダリングが重いので要素そのものは消さない) + for (let rowIdx = newItems.length; rowIdx < usingRows.length; rowIdx++) { + resetRow(rows.value[rowIdx]); + for (let colIdx = 0; colIdx < _cols.length; colIdx++) { + const holder = cells.value[rowIdx]; + holder.origin = {}; + resetCell(holder.cells[colIdx]); + } + } + } + + // 新しい値と既に設定されていた値を入れ替える + const changedCells = Array.of<GridCell>(); + for (let rowIdx = 0; rowIdx < newItems.length; rowIdx++) { + const holder = cells.value[rowIdx]; + holder.row.using = true; + + const oldCells = holder.cells; + const newItem = newItems[rowIdx]; + for (let colIdx = 0; colIdx < oldCells.length; colIdx++) { + const _col = columns.value[colIdx]; + + const oldCell = oldCells[colIdx]; + const newValue = newItem[_col.setting.bindTo]; + if (oldCell.value !== newValue) { + oldCell.value = _col.setting.valueTransformer + ? _col.setting.valueTransformer(holder.row, _col, newValue) + : newValue; + changedCells.push(oldCell); + } + } + } + + if (changedCells.length > 0) { + const allCells = cells.value.slice(0, newItems.length).flatMap(it => it.cells); + for (const cell of allCells) { + cell.violation = cellValidation(allCells, cell, cell.value); + } + + applyRowRules(changedCells); + + // セル値が書き換わっており、バリデーションの結果も変わっているので外部に通知する必要がある + emitGridEvent({ + type: 'cell-validation', + all: cells.value + .filter(it => it.row.using) + .flatMap(it => it.cells) + .map(it => it.violation) + .filter(it => !it.valid), + }); + } + + if (_DEV_) { + console.log('[grid][patch-data][end]'); + } +} + +// endregion +// #endregion + +onMounted(() => { + state.value = 'normal'; + + const bindToList = columnSettings.map(it => it.bindTo); + if (new Set(bindToList).size !== columnSettings.length) { + // 取得元のプロパティ名重複は許容したくない + throw new Error(`Duplicate bindTo setting : [${bindToList.join(',')}]}]`); + } + + if (rootEl.value) { + resizeObserver.observe(rootEl.value); + + // 初期表示時にコンテンツが表示されていない場合はhidden状態にしておく。 + // コンテンツ表示時にresizeイベントが発生するが、そのときにhidden状態にしておかないとサイズの再計算が走らないので + const bounds = rootEl.value.getBoundingClientRect(); + if (bounds.width === 0 || bounds.height === 0) { + state.value = 'hidden'; + } + } + + refreshData(); +}); +</script> + +<style module lang="scss"> +.grid { + font-size: 90%; + overflow-x: scroll; + // firefoxだとスクロールバーがセルに重なって見づらくなってしまうのでスペースを空けておく + padding-bottom: 8px; + + &.noOverflowHandling { + overflow-x: revert; + padding-bottom: 0; + } +} +</style> + +<style lang="scss"> +$borderSetting: solid 0.5px var(--MI_THEME-divider); + +// 配下コンポーネントを含めて一括してコントロールするため、scopedもmoduleも使用できない +.mk_grid_border { + --rootBorderSetting: none; + --borderRadius: 0; + + border-spacing: 0; + + &.mk_grid_root_border { + --rootBorderSetting: #{$borderSetting}; + } + + &.mk_grid_root_rounded { + --borderRadius: var(--MI-radius); + } + + .mk_grid_thead { + .mk_grid_tr { + .mk_grid_th { + border-left: $borderSetting; + border-top: var(--rootBorderSetting); + + &:first-child { + // 左上セル + border-left: var(--rootBorderSetting); + border-top-left-radius: var(--borderRadius); + } + + &:last-child { + // 右上セル + border-top-right-radius: var(--borderRadius); + border-right: var(--rootBorderSetting); + } + } + } + } + + .mk_grid_tbody { + .mk_grid_tr { + .mk_grid_td, .mk_grid_th { + border-left: $borderSetting; + border-top: $borderSetting; + + &:first-child { + // 左端の列 + border-left: var(--rootBorderSetting); + } + + &:last-child { + // 一番右端の列 + border-right: var(--rootBorderSetting); + } + } + } + + .last_row { + .mk_grid_td, .mk_grid_th { + // 一番下の行 + border-bottom: var(--rootBorderSetting); + + &:first-child { + // 左下セル + border-bottom-left-radius: var(--borderRadius); + } + + &:last-child { + // 右下セル + border-bottom-right-radius: var(--borderRadius); + } + } + } + } +} +</style> diff --git a/packages/frontend/src/components/grid/MkHeaderCell.vue b/packages/frontend/src/components/grid/MkHeaderCell.vue new file mode 100644 index 0000000000..aecfe7eaa3 --- /dev/null +++ b/packages/frontend/src/components/grid/MkHeaderCell.vue @@ -0,0 +1,216 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + ref="rootEl" + class="mk_grid_th" + :class="$style.cell" + :style="[{ maxWidth: column.width, minWidth: column.width, width: column.width }]" + data-grid-cell + :data-grid-cell-row="-1" + :data-grid-cell-col="column.index" +> + <div :class="$style.root"> + <div :class="$style.left"></div> + <div :class="$style.wrapper"> + <div ref="contentEl" :class="$style.contentArea"> + <span v-if="column.setting.icon" class="ti" :class="column.setting.icon" style="line-height: normal"></span> + <span v-else>{{ text }}</span> + </div> + </div> + <div + :class="$style.right" + @mousedown="onHandleMouseDown" + @dblclick="onHandleDoubleClick" + ></div> + </div> +</div> +</template> + +<script setup lang="ts"> +import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, watch } from 'vue'; +import { GridEventEmitter, Size } from '@/components/grid/grid.js'; +import { GridColumn } from '@/components/grid/column.js'; + +const emit = defineEmits<{ + (ev: 'operation:beginWidthChange', sender: GridColumn): void; + (ev: 'operation:endWidthChange', sender: GridColumn): void; + (ev: 'operation:widthLargest', sender: GridColumn): void; + (ev: 'change:width', sender: GridColumn, width: string): void; + (ev: 'change:contentSize', sender: GridColumn, newSize: Size): void; +}>(); +const props = defineProps<{ + column: GridColumn, + bus: GridEventEmitter, +}>(); + +const { column, bus } = toRefs(props); + +const rootEl = ref<InstanceType<typeof HTMLTableCellElement>>(); +const contentEl = ref<InstanceType<typeof HTMLDivElement>>(); + +const resizing = ref<boolean>(false); + +const text = computed(() => { + const result = column.value.setting.title ?? column.value.setting.bindTo; + return result.length > 0 ? result : ' '; +}); + +watch(column, () => { + // 中身がセットされた直後はサイズが分からないので、次のタイミングで更新する + nextTick(emitContentSizeChanged); +}, { immediate: true }); + +function onHandleDoubleClick(ev: MouseEvent) { + switch (ev.type) { + case 'dblclick': { + emit('operation:widthLargest', column.value); + break; + } + } +} + +function onHandleMouseDown(ev: MouseEvent) { + switch (ev.type) { + case 'mousedown': { + if (!resizing.value) { + registerHandleMouseUp(); + registerHandleMouseMove(); + resizing.value = true; + emit('operation:beginWidthChange', column.value); + } + break; + } + } +} + +function onHandleMouseMove(ev: MouseEvent) { + if (!rootEl.value) { + // 型ガード + return; + } + + switch (ev.type) { + case 'mousemove': { + if (resizing.value) { + const bounds = rootEl.value.getBoundingClientRect(); + const clientWidth = rootEl.value.clientWidth; + const clientRight = bounds.left + clientWidth; + const nextWidth = clientWidth + (ev.clientX - clientRight); + emit('change:width', column.value, `${nextWidth}px`); + } + break; + } + } +} + +function onHandleMouseUp(ev: MouseEvent) { + switch (ev.type) { + case 'mouseup': { + if (resizing.value) { + unregisterHandleMouseUp(); + unregisterHandleMouseMove(); + resizing.value = false; + emit('operation:endWidthChange', column.value); + } + break; + } + } +} + +function onForceRefreshContentSize() { + emitContentSizeChanged(); +} + +function registerHandleMouseMove() { + unregisterHandleMouseMove(); + addEventListener('mousemove', onHandleMouseMove); +} + +function unregisterHandleMouseMove() { + removeEventListener('mousemove', onHandleMouseMove); +} + +function registerHandleMouseUp() { + unregisterHandleMouseUp(); + addEventListener('mouseup', onHandleMouseUp); +} + +function unregisterHandleMouseUp() { + removeEventListener('mouseup', onHandleMouseUp); +} + +function emitContentSizeChanged() { + const clientWidth = contentEl.value?.clientWidth ?? 0; + const clientHeight = contentEl.value?.clientHeight ?? 0; + emit('change:contentSize', column.value, { + // バーの横幅も考慮したいので、+3px + width: clientWidth + 3 + 3, + height: clientHeight, + }); +} + +onMounted(() => { + bus.value.on('forceRefreshContentSize', onForceRefreshContentSize); +}); + +onUnmounted(() => { + bus.value.off('forceRefreshContentSize', onForceRefreshContentSize); +}); + +</script> + +<style module lang="scss"> +$handleWidth: 5px; +$cellHeight: 28px; + +.cell { + cursor: pointer; +} + +.root { + display: flex; + flex-direction: row; + height: $cellHeight; + max-height: $cellHeight; + min-height: $cellHeight; + + .wrapper { + flex: 1; + display: flex; + flex-direction: row; + overflow: hidden; + justify-content: center; + } + + .contentArea { + display: flex; + padding: 6px 4px; + box-sizing: border-box; + overflow: hidden; + white-space: nowrap; + text-align: center; + } + + .left { + // rightのぶんだけズレるのでそれを相殺するためのネガティブマージン + margin-left: -$handleWidth; + margin-right: auto; + width: $handleWidth; + min-width: $handleWidth; + } + + .right { + margin-left: auto; + // 判定を罫線の上に重ねたいのでネガティブマージンを使う + margin-right: -$handleWidth; + width: $handleWidth; + min-width: $handleWidth; + cursor: w-resize; + z-index: 1; + } +} +</style> diff --git a/packages/frontend/src/components/grid/MkHeaderRow.vue b/packages/frontend/src/components/grid/MkHeaderRow.vue new file mode 100644 index 0000000000..8affa08fd5 --- /dev/null +++ b/packages/frontend/src/components/grid/MkHeaderRow.vue @@ -0,0 +1,60 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + class="mk_grid_tr" + :class="$style.root" + :data-grid-row="-1" +> + <MkNumberCell + v-if="gridSetting.showNumber" + content="#" + :top="true" + /> + <MkHeaderCell + v-for="column in columns" + :key="column.index" + :column="column" + :bus="bus" + @operation:beginWidthChange="(sender) => emit('operation:beginWidthChange', sender)" + @operation:endWidthChange="(sender) => emit('operation:endWidthChange', sender)" + @operation:widthLargest="(sender) => emit('operation:widthLargest', sender)" + @change:width="(sender, width) => emit('change:width', sender, width)" + @change:contentSize="(sender, newSize) => emit('change:contentSize', sender, newSize)" + /> +</div> +</template> + +<script setup lang="ts"> +import { GridEventEmitter, Size } from '@/components/grid/grid.js'; +import MkHeaderCell from '@/components/grid/MkHeaderCell.vue'; +import MkNumberCell from '@/components/grid/MkNumberCell.vue'; +import { GridColumn } from '@/components/grid/column.js'; +import { GridRowSetting } from '@/components/grid/row.js'; + +const emit = defineEmits<{ + (ev: 'operation:beginWidthChange', sender: GridColumn): void; + (ev: 'operation:endWidthChange', sender: GridColumn): void; + (ev: 'operation:widthLargest', sender: GridColumn): void; + (ev: 'operation:selectionColumn', sender: GridColumn): void; + (ev: 'change:width', sender: GridColumn, width: string): void; + (ev: 'change:contentSize', sender: GridColumn, newSize: Size): void; +}>(); + +defineProps<{ + columns: GridColumn[], + gridSetting: GridRowSetting, + bus: GridEventEmitter, +}>(); +</script> + +<style module lang="scss"> +.root { + display: flex; + flex-direction: row; + align-items: center; +} +</style> diff --git a/packages/frontend/src/components/grid/MkNumberCell.vue b/packages/frontend/src/components/grid/MkNumberCell.vue new file mode 100644 index 0000000000..674bba96bc --- /dev/null +++ b/packages/frontend/src/components/grid/MkNumberCell.vue @@ -0,0 +1,61 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + class="mk_grid_th" + :class="[$style.cell]" + :tabindex="-1" + data-grid-cell + :data-grid-cell-row="row?.index ?? -1" + :data-grid-cell-col="-1" +> + <div :class="[$style.root]"> + {{ content }} + </div> +</div> +</template> + +<script setup lang="ts"> + +import { GridRow } from '@/components/grid/row.js'; + +defineProps<{ + content: string, + row?: GridRow, +}>(); + +</script> + +<style module lang="scss"> +$cellHeight: 28px; +$cellWidth: 34px; + +.cell { + overflow: hidden; + white-space: nowrap; + height: $cellHeight; + max-height: $cellHeight; + min-height: $cellHeight; + min-width: $cellWidth; + width: $cellWidth; + cursor: pointer; +} + +.root { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + box-sizing: border-box; + padding: 0 8px; + height: 100%; + border: solid 0.5px transparent; + + &.selected { + background-color: var(--MI_THEME-accentedBg); + } +} +</style> diff --git a/packages/frontend/src/components/grid/cell-validators.ts b/packages/frontend/src/components/grid/cell-validators.ts new file mode 100644 index 0000000000..949cab2ec6 --- /dev/null +++ b/packages/frontend/src/components/grid/cell-validators.ts @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { GridRow } from '@/components/grid/row.js'; +import { i18n } from '@/i18n.js'; + +export type ValidatorParams = { + column: GridColumn; + row: GridRow; + value: CellValue; + allCells: GridCell[]; +}; + +export type ValidatorResult = { + valid: boolean; + message?: string; +} + +export type GridCellValidator = { + name?: string; + ignoreViolation?: boolean; + validate: (params: ValidatorParams) => ValidatorResult; +} + +export type ValidateViolation = { + valid: boolean; + params: ValidatorParams; + violations: ValidateViolationItem[]; +} + +export type ValidateViolationItem = { + valid: boolean; + validator: GridCellValidator; + result: ValidatorResult; +} + +export function cellValidation(allCells: GridCell[], cell: GridCell, newValue: CellValue): ValidateViolation { + const { column, row } = cell; + const validators = column.setting.validators ?? []; + + const params: ValidatorParams = { + column, + row, + value: newValue, + allCells, + }; + + const violations: ValidateViolationItem[] = validators.map(validator => { + const result = validator.validate(params); + return { + valid: result.valid, + validator, + result, + }; + }); + + return { + valid: violations.every(v => v.result.valid), + params, + violations, + }; +} + +class ValidatorPreset { + required(): GridCellValidator { + return { + name: 'required', + validate: ({ value }): ValidatorResult => { + return { + valid: value !== null && value !== undefined && value !== '', + message: i18n.ts._gridComponent._error.requiredValue, + }; + }, + }; + } + + regex(pattern: RegExp): GridCellValidator { + return { + name: 'regex', + validate: ({ value }): ValidatorResult => { + return { + valid: (typeof value !== 'string') || pattern.test(value.toString() ?? ''), + message: i18n.tsx._gridComponent._error.patternNotMatch({ pattern: pattern.source }), + }; + }, + }; + } + + unique(): GridCellValidator { + return { + name: 'unique', + validate: ({ column, row, value, allCells }): ValidatorResult => { + const bindTo = column.setting.bindTo; + const isUnique = allCells + .filter(it => it.column.setting.bindTo === bindTo && it.row.index !== row.index) + .every(cell => cell.value !== value); + return { + valid: isUnique, + message: i18n.ts._gridComponent._error.notUnique, + }; + }, + }; + } +} + +export const validators = new ValidatorPreset(); diff --git a/packages/frontend/src/components/grid/cell.ts b/packages/frontend/src/components/grid/cell.ts new file mode 100644 index 0000000000..71b7a3e3f1 --- /dev/null +++ b/packages/frontend/src/components/grid/cell.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ValidateViolation } from '@/components/grid/cell-validators.js'; +import { Size } from '@/components/grid/grid.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { GridRow } from '@/components/grid/row.js'; +import { MenuItem } from '@/types/menu.js'; +import { GridContext } from '@/components/grid/grid-event.js'; + +export type CellValue = string | boolean | number | undefined | null | Array<unknown> | NonNullable<unknown>; + +export type CellAddress = { + row: number; + col: number; +} + +export const CELL_ADDRESS_NONE: CellAddress = { + row: -1, + col: -1, +}; + +export type GridCell = { + address: CellAddress; + value: CellValue; + column: GridColumn; + row: GridRow; + selected: boolean; + ranged: boolean; + contentSize: Size; + setting: GridCellSetting; + violation: ValidateViolation; +} + +export type GridCellContextMenuFactory = (col: GridColumn, row: GridRow, value: CellValue, context: GridContext) => MenuItem[]; + +export type GridCellSetting = { + contextMenuFactory?: GridCellContextMenuFactory; +} + +export function createCell( + column: GridColumn, + row: GridRow, + value: CellValue, + setting: GridCellSetting, +): GridCell { + const newValue = (row.using && column.setting.valueTransformer) + ? column.setting.valueTransformer(row, column, value) + : value; + + return { + address: { row: row.index, col: column.index }, + value: newValue, + column, + row, + selected: false, + ranged: false, + contentSize: { width: 0, height: 0 }, + violation: { + valid: true, + params: { + column, + row, + value, + allCells: [], + }, + violations: [], + }, + setting, + }; +} + +export function resetCell(cell: GridCell): void { + cell.selected = false; + cell.ranged = false; + cell.violation = { + valid: true, + params: { + column: cell.column, + row: cell.row, + value: cell.value, + allCells: [], + }, + violations: [], + }; +} diff --git a/packages/frontend/src/components/grid/column.ts b/packages/frontend/src/components/grid/column.ts new file mode 100644 index 0000000000..2f505756fe --- /dev/null +++ b/packages/frontend/src/components/grid/column.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { GridCellValidator } from '@/components/grid/cell-validators.js'; +import { Size, SizeStyle } from '@/components/grid/grid.js'; +import { calcCellWidth } from '@/components/grid/grid-utils.js'; +import { CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridRow } from '@/components/grid/row.js'; +import { MenuItem } from '@/types/menu.js'; +import { GridContext } from '@/components/grid/grid-event.js'; + +export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image' | 'hidden'; + +export type CustomValueEditor = (row: GridRow, col: GridColumn, value: CellValue, cellElement: HTMLElement) => Promise<CellValue>; +export type CellValueTransformer = (row: GridRow, col: GridColumn, value: CellValue) => CellValue; +export type GridColumnContextMenuFactory = (col: GridColumn, context: GridContext) => MenuItem[]; + +export type GridColumnSetting = { + bindTo: string; + title?: string; + icon?: string; + type: ColumnType; + width: SizeStyle; + editable?: boolean; + validators?: GridCellValidator[]; + customValueEditor?: CustomValueEditor; + valueTransformer?: CellValueTransformer; + contextMenuFactory?: GridColumnContextMenuFactory; + events?: { + copy?: (value: CellValue) => string; + paste?: (text: string) => CellValue; + delete?: (cell: GridCell, context: GridContext) => void; + } +}; + +export type GridColumn = { + index: number; + setting: GridColumnSetting; + width: string; + contentSize: Size; +} + +export function createColumn(setting: GridColumnSetting, index: number): GridColumn { + return { + index, + setting, + width: calcCellWidth(setting.width), + contentSize: { width: 0, height: 0 }, + }; +} + diff --git a/packages/frontend/src/components/grid/grid-event.ts b/packages/frontend/src/components/grid/grid-event.ts new file mode 100644 index 0000000000..074b72b956 --- /dev/null +++ b/packages/frontend/src/components/grid/grid-event.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridState } from '@/components/grid/grid.js'; +import { ValidateViolation } from '@/components/grid/cell-validators.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { GridRow } from '@/components/grid/row.js'; + +export type GridContext = { + selectedCell?: GridCell; + rangedCells: GridCell[]; + rangedRows: GridRow[]; + randedBounds: { + leftTop: CellAddress; + rightBottom: CellAddress; + }; + availableBounds: { + leftTop: CellAddress; + rightBottom: CellAddress; + }; + state: GridState; + rows: GridRow[]; + columns: GridColumn[]; +}; + +export type GridEvent = + GridCellValueChangeEvent | + GridCellValidationEvent + ; + +export type GridCellValueChangeEvent = { + type: 'cell-value-change'; + column: GridColumn; + row: GridRow; + oldValue: CellValue; + newValue: CellValue; +}; + +export type GridCellValidationEvent = { + type: 'cell-validation'; + violation?: ValidateViolation; + all: ValidateViolation[]; +}; diff --git a/packages/frontend/src/components/grid/grid-utils.ts b/packages/frontend/src/components/grid/grid-utils.ts new file mode 100644 index 0000000000..a45bc88926 --- /dev/null +++ b/packages/frontend/src/components/grid/grid-utils.ts @@ -0,0 +1,215 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { isRef, Ref } from 'vue'; +import { DataSource, SizeStyle } from '@/components/grid/grid.js'; +import { CELL_ADDRESS_NONE, CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridRow } from '@/components/grid/row.js'; +import { GridContext } from '@/components/grid/grid-event.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { GridColumn, GridColumnSetting } from '@/components/grid/column.js'; + +export function isCellElement(elem: HTMLElement): boolean { + return elem.hasAttribute('data-grid-cell'); +} + +export function isRowElement(elem: HTMLElement): boolean { + return elem.hasAttribute('data-grid-row'); +} + +export function calcCellWidth(widthSetting: SizeStyle): string { + switch (widthSetting) { + case undefined: + case 'auto': { + return 'auto'; + } + default: { + return `${widthSetting}px`; + } + } +} + +function getCellRowByAttribute(elem: HTMLElement): number { + const row = elem.getAttribute('data-grid-cell-row'); + if (row === null) { + throw new Error('data-grid-cell-row attribute not found'); + } + return Number(row); +} + +function getCellColByAttribute(elem: HTMLElement): number { + const col = elem.getAttribute('data-grid-cell-col'); + if (col === null) { + throw new Error('data-grid-cell-col attribute not found'); + } + return Number(col); +} + +export function getCellAddress(elem: HTMLElement, parentNodeCount = 10): CellAddress { + let node = elem; + for (let i = 0; i < parentNodeCount; i++) { + if (!node.parentElement) { + break; + } + + if (isCellElement(node) && isRowElement(node.parentElement)) { + const row = getCellRowByAttribute(node); + const col = getCellColByAttribute(node); + + return { row, col }; + } + + node = node.parentElement; + } + + return CELL_ADDRESS_NONE; +} + +export function getCellElement(elem: HTMLElement, parentNodeCount = 10): HTMLElement | null { + let node = elem; + for (let i = 0; i < parentNodeCount; i++) { + if (isCellElement(node)) { + return node; + } + + if (!node.parentElement) { + break; + } + + node = node.parentElement; + } + + return null; +} + +export function equalCellAddress(a: CellAddress, b: CellAddress): boolean { + return a.row === b.row && a.col === b.col; +} + +/** + * グリッドの選択範囲の内容をタブ区切り形式テキストに変換してクリップボードにコピーする。 + */ +export function copyGridDataToClipboard( + gridItems: Ref<DataSource[]> | DataSource[], + context: GridContext, +) { + const items = isRef(gridItems) ? gridItems.value : gridItems; + const lines = Array.of<string>(); + const bounds = context.randedBounds; + + for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) { + const rowItems = Array.of<string>(); + for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) { + const { bindTo, events } = context.columns[col].setting; + const value = items[row][bindTo]; + const transformValue = events?.copy + ? events.copy(value) + : typeof value === 'object' || Array.isArray(value) + ? JSON.stringify(value) + : value?.toString() ?? ''; + rowItems.push(transformValue); + } + lines.push(rowItems.join('\t')); + } + + const text = lines.join('\n'); + copyToClipboard(text); + + if (_DEV_) { + console.log(`Copied to clipboard: ${text}`); + } +} + +/** + * クリップボードからタブ区切りテキストとして値を読み取り、グリッドの選択範囲に貼り付けるためのユーティリティ関数。 + * …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。 + */ +export async function pasteToGridFromClipboard( + context: GridContext, + callback: (row: GridRow, col: GridColumn, parsedValue: CellValue) => void, +) { + function parseValue(value: string, setting: GridColumnSetting): CellValue { + if (setting.events?.paste) { + return setting.events.paste(value); + } else { + switch (setting.type) { + case 'number': { + return Number(value); + } + case 'boolean': { + return value === 'true'; + } + default: { + return value; + } + } + } + } + + const clipBoardText = await navigator.clipboard.readText(); + if (_DEV_) { + console.log(`Paste from clipboard: ${clipBoardText}`); + } + + const bounds = context.randedBounds; + const lines = clipBoardText.replace(/\r/g, '') + .split('\n') + .map(it => it.split('\t')); + + if (lines.length === 1 && lines[0].length === 1) { + // 単独文字列の場合は選択範囲全体に同じテキストを貼り付ける + const ranges = context.rangedCells; + for (const cell of ranges) { + if (cell.column.setting.editable) { + callback(cell.row, cell.column, parseValue(lines[0][0], cell.column.setting)); + } + } + } else { + // 表形式文字列の場合は表形式にパースし、選択範囲に合うように貼り付ける + const offsetRow = bounds.leftTop.row; + const offsetCol = bounds.leftTop.col; + const { columns, rows } = context; + for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) { + const rowIdx = row - offsetRow; + if (lines.length <= rowIdx) { + // クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る + break; + } + + const items = lines[rowIdx]; + for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) { + const colIdx = col - offsetCol; + if (items.length <= colIdx) { + // クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る + break; + } + + if (columns[col].setting.editable) { + callback(rows[row], columns[col], parseValue(items[colIdx], columns[col].setting)); + } + } + } + } +} + +/** + * グリッドの選択範囲にあるデータを削除するためのユーティリティ関数。 + * …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。 + */ +export function removeDataFromGrid( + context: GridContext, + callback: (cell: GridCell) => void, +) { + for (const cell of context.rangedCells) { + const { editable, events } = cell.column.setting; + if (editable) { + if (events?.delete) { + events.delete(cell, context); + } else { + callback(cell); + } + } + } +} diff --git a/packages/frontend/src/components/grid/grid.ts b/packages/frontend/src/components/grid/grid.ts new file mode 100644 index 0000000000..b82e12b304 --- /dev/null +++ b/packages/frontend/src/components/grid/grid.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { EventEmitter } from 'eventemitter3'; +import { CellValue, GridCellSetting } from '@/components/grid/cell.js'; +import { GridColumnSetting } from '@/components/grid/column.js'; +import { GridRowSetting } from '@/components/grid/row.js'; + +export type GridSetting = { + root?: { + noOverflowStyle?: boolean; + rounded?: boolean; + outerBorder?: boolean; + }; + row?: GridRowSetting; + cols: GridColumnSetting[]; + cells?: GridCellSetting; +}; + +export type DataSource = Record<string, CellValue>; + +export type GridState = + 'normal' | + 'cellSelecting' | + 'cellEditing' | + 'colResizing' | + 'colSelecting' | + 'rowSelecting' | + 'hidden' + ; + +export type Size = { + width: number; + height: number; +} + +export type SizeStyle = number | 'auto' | undefined; + +export type AdditionalStyle = { + className?: string; + style?: Record<string, string | number>; +} + +export class GridEventEmitter extends EventEmitter<{ + 'forceRefreshContentSize': void; +}> { +} diff --git a/packages/frontend/src/components/grid/row.ts b/packages/frontend/src/components/grid/row.ts new file mode 100644 index 0000000000..e0a317c9d3 --- /dev/null +++ b/packages/frontend/src/components/grid/row.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { AdditionalStyle } from '@/components/grid/grid.js'; +import { GridCell } from '@/components/grid/cell.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { MenuItem } from '@/types/menu.js'; +import { GridContext } from '@/components/grid/grid-event.js'; + +export const defaultGridRowSetting: Required<GridRowSetting> = { + showNumber: true, + selectable: true, + minimumDefinitionCount: 100, + styleRules: [], + contextMenuFactory: () => [], + events: {}, +}; + +export type GridRowStyleRuleConditionParams = { + row: GridRow, + targetCols: GridColumn[], + cells: GridCell[] +}; + +export type GridRowStyleRule = { + condition: (params: GridRowStyleRuleConditionParams) => boolean; + applyStyle: AdditionalStyle; +} + +export type GridRowContextMenuFactory = (row: GridRow, context: GridContext) => MenuItem[]; + +export type GridRowSetting = { + showNumber?: boolean; + selectable?: boolean; + minimumDefinitionCount?: number; + styleRules?: GridRowStyleRule[]; + contextMenuFactory?: GridRowContextMenuFactory; + events?: { + delete?: (rows: GridRow[]) => void; + } +} + +export type GridRow = { + index: number; + ranged: boolean; + using: boolean; + setting: GridRowSetting; + additionalStyles: AdditionalStyle[]; +} + +export function createRow(index: number, using: boolean, setting: GridRowSetting): GridRow { + return { + index, + ranged: false, + using: using, + setting, + additionalStyles: [], + }; +} + +export function resetRow(row: GridRow): void { + row.ranged = false; + row.using = false; + row.additionalStyles = []; +} + |