fix copy/paste and delete
This commit is contained in:
parent
0f896f6bdb
commit
effe586092
|
@ -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);
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -32,7 +32,6 @@ export type GridCellValueChangeEvent = {
|
|||
type: 'cell-value-change';
|
||||
column: GridColumn;
|
||||
row: GridRow;
|
||||
violation: ValidateViolation;
|
||||
oldValue: CellValue;
|
||||
newValue: CellValue;
|
||||
};
|
||||
|
|
|
@ -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[]> | 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<DataSource[]>, 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[]> | DataSource[], context: GridContext) {
|
||||
const items = typeof gridItems === 'object' ? (gridItems as Ref<DataSource[]>).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 = 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<DataSource[]>,
|
||||
context: GridContext,
|
||||
valueConverters?: { bindTo: string, converter: (value: string) => CellValue }[],
|
||||
) {
|
||||
const converterMap = new Map<string, (value: string) => 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<Record<string, any>[]>, 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();
|
|
@ -10,6 +10,7 @@ export const defaultGridRowSetting: Required<GridRowSetting> = {
|
|||
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 = {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
<MkGrid
|
||||
:data="filteredLogs"
|
||||
:settings="setupGrid()"
|
||||
@event="onGridEvent"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
|
@ -117,9 +117,8 @@ import MkInput from '@/components/MkInput.vue';
|
|||
import MkGrid from '@/components/grid/MkGrid.vue';
|
||||
import { emptyStrToUndefined, RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js';
|
||||
import { GridCellValueChangeEvent, GridContext, GridEvent, GridKeyDownEvent } from '@/components/grid/grid-event.js';
|
||||
import { optInGridUtils } from '@/components/grid/optin-utils.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import XRegisterLogs from '@/pages/admin/custom-emojis-manager.local.logs.vue';
|
||||
import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { GridSetting } from '@/components/grid/grid.js';
|
||||
import MkTagItem from '@/components/MkTagItem.vue';
|
||||
|
|
|
@ -117,16 +117,16 @@ export type KeyEventHandler = {
|
|||
}
|
||||
|
||||
export function handleKeyEvent(event: KeyboardEvent, handlers: KeyEventHandler[]) {
|
||||
function checkModifier(event: KeyboardEvent, modifiers? : KeyModifier[]) {
|
||||
function checkModifier(ev: KeyboardEvent, modifiers? : KeyModifier[]) {
|
||||
if (modifiers) {
|
||||
return modifiers.every(modifier => event.getModifierState(modifier));
|
||||
return modifiers.every(modifier => ev.getModifierState(modifier));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkState(event: KeyboardEvent, states?: KeyState[]) {
|
||||
function checkState(ev: KeyboardEvent, states?: KeyState[]) {
|
||||
if (states) {
|
||||
return states.every(state => event.getModifierState(state));
|
||||
return states.every(state => ev.getModifierState(state));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue