diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue index 5724cb8702..9454b69561 100644 --- a/packages/frontend/src/components/grid/MkGrid.vue +++ b/packages/frontend/src/components/grid/MkGrid.vue @@ -45,7 +45,14 @@ 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 { equalCellAddress, getCellAddress, getCellElement } from '@/components/grid/grid-utils.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'; @@ -255,11 +262,7 @@ function onKeyDown(ev: KeyboardEvent) { case 'normal': { ev.preventDefault(); - const selectedCellAddress = selectedCell.value?.address; - if (!selectedCellAddress) { - return; - } - + const selectedCellAddress = selectedCell.value?.address ?? CELL_ADDRESS_NONE; const max = availableBounds.value; const bounds = rangedBounds.value; @@ -267,6 +270,35 @@ function onKeyDown(ev: KeyboardEvent) { { code: 'any', handler: () => emitGridEvent({ type: 'keydown', event: 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({ @@ -854,19 +886,10 @@ function emitCellValue(sender: GridCell | CellAddress, newValue: CellValue) { const cellAddress = 'address' in sender ? sender.address : sender; const cell = cells.value[cellAddress.row].cells[cellAddress.col]; - const violation = cellValidation(cell, newValue); - cell.violation = violation; - emitGridEvent({ - type: 'cell-validation', - violation: violation, - all: cells.value.flatMap(it => it.cells).map(it => it.violation), - }); - emitGridEvent({ type: 'cell-value-change', column: cell.column, row: cell.row, - violation: violation, oldValue: cell.value, newValue: newValue, }); @@ -876,19 +899,6 @@ function emitCellValue(sender: GridCell | CellAddress, newValue: CellValue) { } } -/** - * {@link selectedCell}のセル番地を取得する。 - * いずれかのセルが選択されている状態で呼ばれることを想定しているため、選択されていない場合は例外を投げる。 - */ -function requireSelectionCell(): CellAddress { - const selected = selectedCell.value; - if (!selected) { - throw new Error('No selected cell'); - } - - return selected.address; -} - /** * {@link target}のセルを選択状態にする。 * その際、{@link target}以外の行およびセルの範囲選択状態を解除する。 @@ -911,7 +921,10 @@ function selectionCell(target: CellAddress) { function selectionRange(...targets: CellAddress[]) { const _cells = cells.value; for (const target of targets) { - _cells[target.row].cells[target.col].ranged = true; + const row = _cells[target.row]; + if (row.row.using) { + row.cells[target.col].ranged = true; + } } } @@ -935,16 +948,18 @@ function unSelectionRangeAll() { * {@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 < leftTop.col || cell.address.col > rightBottom.col; - const outOfRangeRow = cell.address.row < leftTop.row || cell.address.row > rightBottom.row; + 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 < leftTop.row || index > rightBottom.row); + const outOfRangeRows = rows.value.filter((_, index) => index < safeBounds.leftTop.row || index > safeBounds.rightBottom.row); for (const row of outOfRangeRows) { row.ranged = false; } @@ -954,9 +969,10 @@ function unSelectionOutOfRange(leftTop: CellAddress, rightBottom: CellAddress) { * {@link leftTop}から{@link rightBottom}の範囲内にあるセルを範囲選択状態にする。 */ function expandCellRange(leftTop: CellAddress, rightBottom: CellAddress) { - const targetRows = cells.value.slice(leftTop.row, rightBottom.row + 1); + 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(leftTop.col, rightBottom.col + 1)) { + for (const cell of row.cells.slice(safeBounds.leftTop.col, safeBounds.rightBottom.col + 1)) { cell.ranged = true; } } @@ -989,10 +1005,10 @@ function applyRowRules(targetCells: GridCell[]) { for (const group of rowGroups.filter(it => it.row.using)) { const row = group.row; const targetCols = group.cells.map(it => it.column); - const cells = _cells[group.row.index].cells; + const rowCells = _cells[group.row.index].cells; const newStyles = rowSetting.styleRules - .filter(it => it.condition({ row, targetCols, cells })) + .filter(it => it.condition({ row, targetCols, cells: rowCells })) .map(it => it.applyStyle); if (JSON.stringify(newStyles) !== JSON.stringify(row.additionalStyles)) { @@ -1002,7 +1018,11 @@ function applyRowRules(targetCells: GridCell[]) { } function availableCellAddress(cellAddress: CellAddress): boolean { - return cellAddress.row >= 0 && cellAddress.col >= 0 && cellAddress.row < rows.value.length && cellAddress.col < columns.value.length; + 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 { @@ -1013,6 +1033,23 @@ 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); diff --git a/packages/frontend/src/components/grid/column.ts b/packages/frontend/src/components/grid/column.ts index c1dc4514e4..5e189224c1 100644 --- a/packages/frontend/src/components/grid/column.ts +++ b/packages/frontend/src/components/grid/column.ts @@ -1,7 +1,7 @@ import { CellValidator } from '@/components/grid/cell-validators.js'; import { Size, SizeStyle } from '@/components/grid/grid.js'; import { calcCellWidth } from '@/components/grid/grid-utils.js'; -import { CellValue } from '@/components/grid/cell.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'; @@ -23,6 +23,11 @@ export type GridColumnSetting = { customValueEditor?: CustomValueEditor; valueTransformer?: CellValueTransformer; contextMenuFactory?: GridColumnContextMenuFactory; + events?: { + copy?: (value: CellValue) => string; + paste?: (text: string) => CellValue; + delete?: (cell: GridCell, context: GridContext) => void; + } }; export type GridColumn = { diff --git a/packages/frontend/src/components/grid/grid-event.ts b/packages/frontend/src/components/grid/grid-event.ts index ebb32fee2c..5c3b6e1019 100644 --- a/packages/frontend/src/components/grid/grid-event.ts +++ b/packages/frontend/src/components/grid/grid-event.ts @@ -32,7 +32,6 @@ export type GridCellValueChangeEvent = { type: 'cell-value-change'; column: GridColumn; row: GridRow; - violation: ValidateViolation; oldValue: CellValue; newValue: CellValue; }; diff --git a/packages/frontend/src/components/grid/grid-utils.ts b/packages/frontend/src/components/grid/grid-utils.ts index 6507bf9d9c..39198b7819 100644 --- a/packages/frontend/src/components/grid/grid-utils.ts +++ b/packages/frontend/src/components/grid/grid-utils.ts @@ -1,6 +1,10 @@ -import { SizeStyle } from '@/components/grid/grid.js'; -import { CELL_ADDRESS_NONE, CellAddress } from '@/components/grid/cell.js'; -import { GridRowSetting } from '@/components/grid/row.js'; +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, GridRowSetting } 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: any): elem is HTMLTableCellElement { return elem instanceof HTMLTableCellElement; @@ -65,3 +69,124 @@ export function equalCellAddress(a: CellAddress, b: CellAddress): boolean { return a.row === b.row && a.col === b.col; } +/** + * グリッドの選択範囲の内容をタブ区切り形式テキストに変換してクリップボードにコピーする。 + */ +export function copyGridDataToClipboard( + gridItems: Ref | DataSource[], + context: GridContext, +) { + const items = isRef(gridItems) ? gridItems.value : gridItems; + const lines = Array.of(); + const bounds = context.randedBounds; + + for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) { + const rowItems = Array.of(); + 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) { + 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; + } + + 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/optin-utils.ts b/packages/frontend/src/components/grid/optin-utils.ts deleted file mode 100644 index 0ec70eab71..0000000000 --- a/packages/frontend/src/components/grid/optin-utils.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { Ref } from 'vue'; -import { GridContext, GridKeyDownEvent } from '@/components/grid/grid-event.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; -import { GridColumnSetting } from '@/components/grid/column.js'; -import { CellValue } from '@/components/grid/cell.js'; -import { DataSource } from '@/components/grid/grid.js'; - -class OptInGridUtils { - async defaultKeyDownHandler(gridItems: Ref, event: GridKeyDownEvent, context: GridContext) { - const { ctrlKey, shiftKey, code } = event.event; - - switch (true) { - case ctrlKey && shiftKey: { - break; - } - case ctrlKey: { - switch (code) { - case 'KeyC': { - this.copyToClipboard(gridItems, context); - break; - } - case 'KeyV': { - await this.pasteFromClipboard(gridItems, context); - break; - } - } - break; - } - case shiftKey: { - break; - } - default: { - switch (code) { - case 'Delete': { - this.deleteSelectionRange(gridItems, context); - break; - } - } - break; - } - } - } - - copyToClipboard(gridItems: Ref | DataSource[], context: GridContext) { - const items = typeof gridItems === 'object' ? (gridItems as Ref).value : gridItems; - const lines = Array.of(); - const bounds = context.randedBounds; - - for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) { - const rowItems = Array.of(); - for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) { - const bindTo = context.columns[col].setting.bindTo; - const cell = items[row][bindTo]; - const value = typeof cell === 'object' || Array.isArray(cell) - ? JSON.stringify(cell) - : cell?.toString() ?? ''; - rowItems.push(value); - } - lines.push(rowItems.join('\t')); - } - - const text = lines.join('\n'); - copyToClipboard(text); - - if (_DEV_) { - console.log(`Copied to clipboard: ${text}`); - } - } - - async pasteFromClipboard( - gridItems: Ref, - context: GridContext, - valueConverters?: { bindTo: string, converter: (value: string) => CellValue }[], - ) { - const converterMap = new Map CellValue>(valueConverters?.map(it => [it.bindTo, it.converter]) ?? []); - - function parseValue(value: string, setting: GridColumnSetting): CellValue { - switch (setting.type) { - case 'number': { - return Number(value); - } - case 'boolean': { - return value === 'true'; - } - default: { - return converterMap.has(setting.bindTo) - ? converterMap.get(setting.bindTo)!(value) - : 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) { - gridItems.value[cell.row.index][cell.column.setting.bindTo] = parseValue(lines[0][0], cell.column.setting); - } - } else { - // 表形式文字列の場合は表形式にパースし、選択範囲に合うように貼り付ける - const offsetRow = bounds.leftTop.row; - const offsetCol = bounds.leftTop.col; - const columns = context.columns; - 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; - } - - gridItems.value[row][columns[col].setting.bindTo] = parseValue(items[colIdx], columns[col].setting); - } - } - } - } - - deleteSelectionRange(gridItems: Ref[]>, context: GridContext) { - if (context.rangedRows.length > 0) { - const deletedIndexes = context.rangedRows.map(it => it.index); - gridItems.value = gridItems.value.filter((_, index) => !deletedIndexes.includes(index)); - } else { - const ranges = context.rangedCells; - for (const cell of ranges) { - if (cell.column.setting.editable) { - gridItems.value[cell.row.index][cell.column.setting.bindTo] = undefined; - } - } - } - } -} - -export const optInGridUtils = new OptInGridUtils(); diff --git a/packages/frontend/src/components/grid/row.ts b/packages/frontend/src/components/grid/row.ts index 4f6898ade8..8d557c6cba 100644 --- a/packages/frontend/src/components/grid/row.ts +++ b/packages/frontend/src/components/grid/row.ts @@ -10,6 +10,7 @@ export const defaultGridRowSetting: Required = { minimumDefinitionCount: 100, styleRules: [], contextMenuFactory: () => [], + events: {}, }; export type GridRowStyleRuleConditionParams = { @@ -31,6 +32,9 @@ export type GridRowSetting = { minimumDefinitionCount?: number; styleRules?: GridRowStyleRule[]; contextMenuFactory?: GridRowContextMenuFactory; + events?: { + delete?: (rows: GridRow[]) => void; + } } export type GridRow = { diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue index 277cfad258..03c0e231f0 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue @@ -196,25 +196,18 @@ import { i18n } from '@/i18n.js'; import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; import { validators } from '@/components/grid/cell-validators.js'; -import { - GridCellValidationEvent, - GridCellValueChangeEvent, - GridContext, - GridEvent, - GridKeyDownEvent, -} from '@/components/grid/grid-event.js'; -import { optInGridUtils } from '@/components/grid/optin-utils.js'; +import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import MkPagingButtons from '@/components/MkPagingButtons.vue'; -import XRegisterLogs from '@/pages/admin/custom-emojis-manager.local.logs.vue'; +import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSelect from '@/components/MkSelect.vue'; import { deviceKind } from '@/scripts/device-kind.js'; import { GridSetting } from '@/components/grid/grid.js'; import MkTagItem from '@/components/MkTagItem.vue'; import { MenuItem } from '@/types/menu.js'; -import { CellValue } from '@/components/grid/cell.js'; import { selectFile } from '@/scripts/select-file.js'; +import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js'; type GridItem = { checked: boolean; @@ -275,26 +268,33 @@ function setupGrid(): GridSetting { type: 'button', text: '選択行をコピー', icon: 'ti ti-copy', - action: () => optInGridUtils.copyToClipboard(gridItems, context), + action: () => copyGridDataToClipboard(gridItems, context), }, { type: 'button', text: '選択行を削除対象とする', icon: 'ti ti-trash', action: () => { - for (const row of context.rangedRows) { - gridItems.value[row.index].checked = true; + for (const rangedRow of context.rangedRows) { + gridItems.value[rangedRow.index].checked = true; } }, }, ]; }, + events: { + delete(rows) { + for (const row of rows) { + gridItems.value[row.index].checked = true; + } + }, + }, }, cols: [ { bindTo: 'checked', icon: 'ti-trash', type: 'boolean', editable: true, width: 34 }, { bindTo: 'url', icon: 'ti-icons', type: 'image', editable: true, width: 'auto', validators: [required], - customValueEditor: async (row, col, value, cellElement) => { + async customValueEditor(row, col, value, cellElement) { const file = await selectFile(cellElement); gridItems.value[row.index].url = file.url; gridItems.value[row.index].fileId = file.id; @@ -310,13 +310,13 @@ function setupGrid(): GridSetting { { bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 }, { bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140, - valueTransformer: (row) => { + valueTransformer(row) { // バックエンドからからはIDと名前のペア配列で受け取るが、表示にIDがあると煩雑なので名前だけにする return gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction .map((it) => it.name) .join(','); }, - customValueEditor: async (row) => { + async customValueEditor(row) { // ID直記入は体験的に最悪なのでモーダルを使って入力する const current = gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction; const result = await os.selectRole({ @@ -334,27 +334,54 @@ function setupGrid(): GridSetting { return transform; }, + events: { + paste(text) { + // idとnameのペア配列をJSONで受け取る。それ以外の形式は許容しない + try { + const obj = JSON.parse(text); + if (!Array.isArray(obj)) { + return []; + } + if (!obj.every(it => typeof it === 'object' && 'id' in it && 'name' in it)) { + return []; + } + + return obj.map(it => ({ id: it.id, name: it.name })); + } catch (ex) { + console.warn(ex); + return []; + } + }, + delete(cell) { + // デフォルトはundefinedになるが、このプロパティは空配列にしたい + gridItems.value[cell.row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = []; + }, + }, }, { bindTo: 'updatedAt', type: 'text', editable: false, width: 'auto' }, { bindTo: 'publicUrl', type: 'text', editable: false, width: 180 }, { bindTo: 'originalUrl', type: 'text', editable: false, width: 180 }, ], cells: { - contextMenuFactory: (col, row, value, context) => { + contextMenuFactory(col, row, value, context) { return [ { type: 'button', text: '選択範囲をコピー', icon: 'ti ti-copy', action: () => { - return optInGridUtils.copyToClipboard(gridItems, context); + return copyGridDataToClipboard(gridItems, context); }, }, { type: 'button', text: '選択範囲を削除', icon: 'ti ti-trash', - action: () => optInGridUtils.deleteSelectionRange(gridItems, context), + action: () => { + removeDataFromGrid(context, (cell) => { + gridItems.value[cell.row.index][cell.column.setting.bindTo] = undefined; + }); + }, }, { type: 'button', @@ -567,7 +594,7 @@ async function onPageChanged(pageNumber: number) { await refreshCustomEmojis(); } -function onGridEvent(event: GridEvent, currentState: GridContext) { +function onGridEvent(event: GridEvent) { switch (event.type) { case 'cell-validation': onGridCellValidation(event); @@ -575,9 +602,6 @@ function onGridEvent(event: GridEvent, currentState: GridContext) { case 'cell-value-change': onGridCellValueChange(event); break; - case 'keydown': - onGridKeyDown(event, currentState); - break; } } @@ -592,74 +616,6 @@ function onGridCellValueChange(event: GridCellValueChangeEvent) { } } -async function onGridKeyDown(event: GridKeyDownEvent, currentState: GridContext) { - function roleIdConverter(value: string): CellValue { - try { - const obj = JSON.parse(value); - if (!Array.isArray(obj)) { - return []; - } - if (!obj.every(it => typeof it === 'object' && 'id' in it && 'name' in it)) { - return []; - } - - return obj.map(it => ({ id: it.id, name: it.name })); - } catch (ex) { - return []; - } - } - - const { ctrlKey, shiftKey, code } = event.event; - - switch (true) { - case ctrlKey && shiftKey: { - break; - } - case ctrlKey: { - switch (code) { - case 'KeyC': { - optInGridUtils.copyToClipboard(gridItems, currentState); - break; - } - case 'KeyV': { - await optInGridUtils.pasteFromClipboard( - gridItems, - currentState, - [ - { bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', converter: roleIdConverter }, - ], - ); - break; - } - } - break; - } - case shiftKey: { - break; - } - default: { - switch (code) { - case 'Delete': { - if (currentState.rangedRows.length > 0) { - for (const row of currentState.rangedRows) { - gridItems.value[row.index].checked = true; - } - } else { - const ranges = currentState.rangedCells; - for (const cell of ranges) { - if (cell.column.setting.editable) { - gridItems.value[cell.row.index][cell.column.setting.bindTo] = undefined; - } - } - } - break; - } - } - break; - } - } -} - async function refreshCustomEmojis() { const limit = 100; diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue index a17428771a..2511ccbc66 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue @@ -87,18 +87,12 @@ import * as os from '@/os.js'; import { validators } from '@/components/grid/cell-validators.js'; import { chooseFileFromDrive, chooseFileFromPc } from '@/scripts/select-file.js'; import { uploadFile } from '@/scripts/upload.js'; -import { - GridCellValidationEvent, - GridCellValueChangeEvent, - GridContext, - GridEvent, - GridKeyDownEvent, -} from '@/components/grid/grid-event.js'; +import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; import { DroppedFile, extractDroppedItems, flattenDroppedFiles } from '@/scripts/file-drop.js'; -import { optInGridUtils } from '@/components/grid/optin-utils.js'; -import XRegisterLogs from '@/pages/admin/custom-emojis-manager.local.logs.vue'; +import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; import { GridSetting } from '@/components/grid/grid.js'; -import { CellValue } from '@/components/grid/cell.js'; +import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js'; +import { GridRow } from '@/components/grid/row.js'; const MAXIMUM_EMOJI_REGISTER_COUNT = 100; @@ -124,6 +118,11 @@ function setupGrid(): GridSetting { const required = validators.required(); const regex = validators.regex(/^[a-zA-Z0-9_]+$/); + function removeRows(rows: GridRow[]) { + const idxes = [...new Set(rows.map(it => it.index))]; + gridItems.value = gridItems.value.filter((_, i) => !idxes.includes(i)); + } + return { row: { showNumber: true, @@ -141,16 +140,21 @@ function setupGrid(): GridSetting { type: 'button', text: '選択行をコピー', icon: 'ti ti-copy', - action: () => optInGridUtils.copyToClipboard(gridItems, context), + action: () => copyGridDataToClipboard(gridItems, context), }, { type: 'button', text: '選択行を削除', icon: 'ti ti-trash', - action: () => optInGridUtils.deleteSelectionRange(gridItems, context), + action: () => removeRows(context.rangedRows), }, ]; }, + events: { + delete(rows) { + removeRows(rows); + }, + }, }, cols: [ { bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto', validators: [required] }, @@ -195,13 +199,13 @@ function setupGrid(): GridSetting { type: 'button', text: '選択範囲をコピー', icon: 'ti ti-copy', - action: () => optInGridUtils.copyToClipboard(gridItems, context), + action: () => copyGridDataToClipboard(gridItems, context), }, { type: 'button', text: '選択行を削除', icon: 'ti ti-trash', - action: () => optInGridUtils.deleteSelectionRange(gridItems, context), + action: () => removeRows(context.rangedCells.map(it => it.row)), }, ]; }, @@ -359,7 +363,7 @@ async function onDriveSelectClicked() { gridItems.value.push(...driveFiles.map(fromDriveFile)); } -function onGridEvent(event: GridEvent, currentState: GridContext) { +function onGridEvent(event: GridEvent) { switch (event.type) { case 'cell-validation': onGridCellValidation(event); @@ -367,9 +371,6 @@ function onGridEvent(event: GridEvent, currentState: GridContext) { case 'cell-value-change': onGridCellValueChange(event); break; - case 'keydown': - onGridKeyDown(event, currentState); - break; } } @@ -384,63 +385,6 @@ function onGridCellValueChange(event: GridCellValueChangeEvent) { } } -async function onGridKeyDown(event: GridKeyDownEvent, context: GridContext) { - function roleIdConverter(value: string): CellValue { - try { - const obj = JSON.parse(value); - if (!Array.isArray(obj)) { - return []; - } - if (!obj.every(it => typeof it === 'object' && 'id' in it && 'name' in it)) { - return []; - } - - return obj.map(it => ({ id: it.id, name: it.name })); - } catch (ex) { - return []; - } - } - - const { ctrlKey, shiftKey, code } = event.event; - - switch (true) { - case ctrlKey && shiftKey: { - break; - } - case ctrlKey: { - switch (code) { - case 'KeyC': { - optInGridUtils.copyToClipboard(gridItems, context); - break; - } - case 'KeyV': { - await optInGridUtils.pasteFromClipboard( - gridItems, - context, - [ - { bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', converter: roleIdConverter }, - ], - ); - break; - } - } - break; - } - case shiftKey: { - break; - } - default: { - switch (code) { - case 'Delete': { - optInGridUtils.deleteSelectionRange(gridItems, context); - break; - } - } - break; - } - } -} - function fromDriveFile(it: Misskey.entities.DriveFile): GridItem { return { fileId: it.id, diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.logs.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.logs.vue similarity index 76% rename from packages/frontend/src/pages/admin/custom-emojis-manager.local.logs.vue rename to packages/frontend/src/pages/admin/custom-emojis-manager.logs.vue index bed2002ae2..7b920a97c9 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.logs.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.logs.vue @@ -9,7 +9,6 @@
@@ -27,15 +26,10 @@ import { computed, ref, toRefs } from 'vue'; import { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js'; -import { - GridContext, - GridEvent, - GridKeyDownEvent, -} from '@/components/grid/grid-event.js'; -import { optInGridUtils } from '@/components/grid/optin-utils.js'; import MkGrid from '@/components/grid/MkGrid.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import { GridSetting } from '@/components/grid/grid.js'; +import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js'; function setupGrid(): GridSetting { return { @@ -48,7 +42,7 @@ function setupGrid(): GridSetting { type: 'button', text: '選択行をコピー', icon: 'ti ti-copy', - action: () => optInGridUtils.copyToClipboard(logs, context), + action: () => copyGridDataToClipboard(logs, context), }, ]; }, @@ -66,7 +60,7 @@ function setupGrid(): GridSetting { type: 'button', text: '選択範囲をコピー', icon: 'ti ti-copy', - action: () => optInGridUtils.copyToClipboard(logs, context), + action: () => copyGridDataToClipboard(logs, context), }, ]; }, @@ -86,18 +80,6 @@ const filteredLogs = computed(() => { return logs.value.filter((log) => forceShowing || log.failed); }); -function onGridEvent(event: GridEvent, currentState: GridContext) { - switch (event.type) { - case 'keydown': - onGridKeyDown(event, currentState); - break; - } -} - -function onGridKeyDown(event: GridKeyDownEvent, currentState: GridContext) { - optInGridUtils.defaultKeyDownHandler(logs, event, currentState); -} -