fix utils

This commit is contained in:
samunohito 2024-02-03 15:01:59 +09:00
parent e6ec32126f
commit f9e866e733
7 changed files with 305 additions and 213 deletions

View File

@ -66,7 +66,7 @@ import { GridEventEmitter, Size } from '@/components/grid/grid.js';
import { useTooltip } from '@/scripts/use-tooltip.js'; import { useTooltip } from '@/scripts/use-tooltip.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { CellValue, GridCell } from '@/components/grid/cell.js'; import { CellValue, GridCell } from '@/components/grid/cell.js';
import { equalCellAddress, getCellAddress } from '@/components/grid/utils.js'; import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js';
import { cellValidation, ValidateViolation } from '@/components/grid/cell-validators.js'; import { cellValidation, ValidateViolation } from '@/components/grid/cell-validators.js';
const emit = defineEmits<{ const emit = defineEmits<{

View File

@ -43,7 +43,7 @@ import MkDataRow from '@/components/grid/MkDataRow.vue';
import MkHeaderRow from '@/components/grid/MkHeaderRow.vue'; import MkHeaderRow from '@/components/grid/MkHeaderRow.vue';
import { cellValidation } from '@/components/grid/cell-validators.js'; import { cellValidation } from '@/components/grid/cell-validators.js';
import { CELL_ADDRESS_NONE, CellAddress, CellValue, createCell, GridCell } from '@/components/grid/cell.js'; import { CELL_ADDRESS_NONE, CellAddress, CellValue, createCell, GridCell } from '@/components/grid/cell.js';
import { equalCellAddress, getCellAddress } from '@/components/grid/utils.js'; import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js';
import { MenuItem } from '@/types/menu.js'; import { MenuItem } from '@/types/menu.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { GridCurrentState, GridEvent } from '@/components/grid/grid-event.js'; import { GridCurrentState, GridEvent } from '@/components/grid/grid-event.js';
@ -774,6 +774,10 @@ function emitCellValue(sender: GridCell | CellAddress, newValue: CellValue) {
oldValue: cell.value, oldValue: cell.value,
newValue: newValue, newValue: newValue,
}); });
if (_DEV_) {
console.log(`[grid][cell-value] row:${cell.row}, col:${cell.column.index}, value:${newValue}`);
}
} }
} }

View File

@ -1,6 +1,6 @@
import { CellValidator } from '@/components/grid/cell-validators.js'; import { CellValidator } from '@/components/grid/cell-validators.js';
import { Size, SizeStyle } from '@/components/grid/grid.js'; import { Size, SizeStyle } from '@/components/grid/grid.js';
import { calcCellWidth } from '@/components/grid/utils.js'; import { calcCellWidth } from '@/components/grid/grid-utils.js';
export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image'; export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image';

View File

@ -1,4 +1,4 @@
import { SizeStyle } from '@/components/grid/types.js'; import { SizeStyle } from '@/components/grid/grid.js';
import { CELL_ADDRESS_NONE, CellAddress } from '@/components/grid/cell.js'; import { CELL_ADDRESS_NONE, CellAddress } from '@/components/grid/cell.js';
export function isCellElement(elem: any): elem is HTMLTableCellElement { export function isCellElement(elem: any): elem is HTMLTableCellElement {

View File

@ -0,0 +1,146 @@
import { Ref } from 'vue';
import { GridCellValueChangeEvent, GridCurrentState, GridKeyDownEvent } from '@/components/grid/grid-event.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { ColumnSetting } from '@/components/grid/column.js';
import { CellValue } from '@/components/grid/cell.js';
import { DataSource } from '@/components/grid/grid.js';
class OptInGridUtils {
async applyCellValueFromEvent(gridItems: Ref<DataSource[]>, event: GridCellValueChangeEvent) {
const { row, column, newValue } = event;
gridItems.value[row.index][column.setting.bindTo] = newValue;
}
async commonKeyDownHandler(gridItems: Ref<DataSource[]>, event: GridKeyDownEvent, currentState: GridCurrentState) {
const { ctrlKey, shiftKey, code } = event.event;
switch (true) {
case ctrlKey && shiftKey: {
break;
}
case ctrlKey: {
switch (code) {
case 'KeyC': {
this.rangeCopyToClipboard(gridItems, currentState);
break;
}
case 'KeyV': {
await this.pasteFromClipboard(gridItems, currentState);
break;
}
}
break;
}
case shiftKey: {
break;
}
default: {
switch (code) {
case 'Delete': {
this.deleteSelectionRange(gridItems, currentState);
break;
}
}
break;
}
}
}
rangeCopyToClipboard(gridItems: Ref<DataSource[]>, currentState: GridCurrentState) {
const lines = Array.of<string>();
const bounds = currentState.randedBounds;
for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) {
const items = Array.of<string>();
for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) {
const bindTo = currentState.columns[col].setting.bindTo;
const cell = gridItems.value[row][bindTo];
items.push(cell?.toString() ?? '');
}
lines.push(items.join('\t'));
}
const text = lines.join('\n');
copyToClipboard(text);
if (_DEV_) {
console.log(`Copied to clipboard: ${text}`);
}
}
async pasteFromClipboard(
gridItems: Ref<DataSource[]>,
currentState: GridCurrentState,
) {
function parseValue(value: string, type: ColumnSetting['type']): CellValue {
switch (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 = currentState.randedBounds;
const lines = clipBoardText.replace(/\r/g, '')
.split('\n')
.map(it => it.split('\t'));
if (lines.length === 1 && lines[0].length === 1) {
// 単独文字列の場合は選択範囲全体に同じテキストを貼り付ける
const ranges = currentState.rangedCells;
for (const cell of ranges) {
gridItems.value[cell.row.index][cell.column.setting.bindTo] = parseValue(lines[0][0], cell.column.setting.type);
}
} else {
// 表形式文字列の場合は表形式にパースし、選択範囲に合うように貼り付ける
const offsetRow = bounds.leftTop.row;
const offsetCol = bounds.leftTop.col;
const columns = currentState.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.type);
}
}
}
}
deleteSelectionRange(gridItems: Ref<DataSource[]>, currentState: GridCurrentState) {
if (currentState.rangedRows.length > 0) {
const deletedIndexes = currentState.rangedRows.map(it => it.index);
gridItems.value = gridItems.value.filter((_, index) => !deletedIndexes.includes(index));
} 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;
}
}
}
}
}
export const optInGridUtils = new OptInGridUtils();

View File

@ -56,8 +56,8 @@
</div> </div>
<ul> <ul>
<li>この枠に画像ファイルまたはディレクトリをドラッグドロップ</li> <li>この枠に画像ファイルまたはディレクトリをドラッグドロップ</li>
<li><a @click="onFileSelectClicked">このリンクをクリックしてPCから選択する</a></li> <li><a @click.prevent="onFileSelectClicked">このリンクをクリックしてPCから選択する</a></li>
<li><a @click="onDriveSelectClicked">このリンクをクリックしてドライブから選択する</a></li> <li><a @click.prevent="onDriveSelectClicked">このリンクをクリックしてドライブから選択する</a></li>
</ul> </ul>
</div> </div>
@ -88,7 +88,7 @@
<script setup lang="ts"> <script setup lang="ts">
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { fromDriveFile, GridItem } from '@/pages/admin/custom-emojis-grid.impl.js'; import { fromDriveFile, GridItem } from '@/pages/admin/custom-emojis-grid.impl.js';
import MkGrid from '@/components/grid/MkGrid.vue'; import MkGrid from '@/components/grid/MkGrid.vue';
@ -103,6 +103,7 @@ import { required } from '@/components/grid/cell-validators.js';
import { chooseFileFromDrive, chooseFileFromPc } from '@/scripts/select-file.js'; import { chooseFileFromDrive, chooseFileFromPc } from '@/scripts/select-file.js';
import { uploadFile } from '@/scripts/upload.js'; import { uploadFile } from '@/scripts/upload.js';
import { import {
GridCellContextMenuEvent,
GridCellValidationEvent, GridCellValidationEvent,
GridCellValueChangeEvent, GridCellValueChangeEvent,
GridCurrentState, GridCurrentState,
@ -113,7 +114,8 @@ import {
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { CellValue } from '@/components/grid/cell.js'; import { CellValue } from '@/components/grid/cell.js';
import { ColumnSetting } from '@/components/grid/column.js'; import { ColumnSetting } from '@/components/grid/column.js';
import { GridRow } from '@/components/grid/row.js'; import { DroppedFile, readDataTransferItems, flattenDroppedFiles, extractDroppedItems } from '@/scripts/file-drop.js';
import { optInGridUtils } from '@/components/grid/optin-utils.js';
type FolderItem = { type FolderItem = {
id?: string; id?: string;
@ -134,20 +136,6 @@ type RegisterLogItem = {
error?: string; error?: string;
}; };
type DroppedItem = DroppedFile | DroppedDirectory;
type DroppedFile = {
isFile: true;
path: string;
file: File;
};
type DroppedDirectory = {
isFile: false;
path: string;
children: DroppedItem[];
}
const columnSettings: ColumnSetting[] = [ const columnSettings: ColumnSetting[] = [
{ bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto', validators: [required] }, { bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto', validators: [required] },
{ bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140, validators: [required] }, { bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140, validators: [required] },
@ -248,35 +236,7 @@ async function onClearClicked() {
} }
async function onDrop(ev: DragEvent) { async function onDrop(ev: DragEvent) {
const dropItems = ev.dataTransfer?.items; const droppedFiles = await extractDroppedItems(ev).then(it => flattenDroppedFiles(it));
if (!dropItems || dropItems.length === 0) {
return;
}
const droppedFiles = Array.of<DroppedFile>();
const apiTestItem = dropItems[0];
if ('webkitGetAsEntry' in apiTestItem) {
const droppedItems = await eachDroppedItems(dropItems);
droppedFiles.push(...flattenDroppedItems(droppedItems).filter(it => it.isFile));
} else {
// webkitGetAsEntryfiles
const dropFiles = ev.dataTransfer.files;
if (dropFiles.length === 0) {
return;
}
for (let i = 0; i < dropFiles.length; i++) {
const file = dropFiles.item(i);
if (file) {
droppedFiles.push({
isFile: true,
path: file.name,
file,
});
}
}
}
const uploadedItems = await Promise.all( const uploadedItems = await Promise.all(
droppedFiles.map(async (it) => ({ droppedFiles.map(async (it) => ({
droppedFile: it, droppedFile: it,
@ -303,7 +263,6 @@ async function onDrop(ev: DragEvent) {
} }
async function onFileSelectClicked(ev: MouseEvent) { async function onFileSelectClicked(ev: MouseEvent) {
ev.preventDefault();
const driveFiles = await chooseFileFromPc( const driveFiles = await chooseFileFromPc(
true, true,
{ {
@ -317,16 +276,10 @@ async function onFileSelectClicked(ev: MouseEvent) {
} }
async function onDriveSelectClicked(ev: MouseEvent) { async function onDriveSelectClicked(ev: MouseEvent) {
ev.preventDefault();
const driveFiles = await chooseFileFromDrive(true); const driveFiles = await chooseFileFromDrive(true);
gridItems.value.push(...driveFiles.map(fromDriveFile)); gridItems.value.push(...driveFiles.map(fromDriveFile));
} }
function onRowDeleting(rows: GridRow[]) {
const deletedIndexes = rows.map(it => it.index);
gridItems.value = gridItems.value.filter((_, index) => !deletedIndexes.includes(index));
}
function onGridEvent(event: GridEvent, currentState: GridCurrentState) { function onGridEvent(event: GridEvent, currentState: GridCurrentState) {
switch (event.type) { switch (event.type) {
case 'cell-validation': case 'cell-validation':
@ -335,6 +288,9 @@ function onGridEvent(event: GridEvent, currentState: GridCurrentState) {
case 'row-context-menu': case 'row-context-menu':
onGridRowContextMenu(event, currentState); onGridRowContextMenu(event, currentState);
break; break;
case 'cell-context-menu':
onGridCellContextMenu(event, currentState);
break;
case 'cell-value-change': case 'cell-value-change':
onGridCellValueChange(event, currentState); onGridCellValueChange(event, currentState);
break; break;
@ -352,170 +308,42 @@ function onGridRowContextMenu(event: GridRowContextMenuEvent, currentState: Grid
event.menuItems.push( event.menuItems.push(
{ {
type: 'button', type: 'button',
text: '行を削除', text: '選択行をコピー',
icon: 'ti ti-copy',
action: () => optInGridUtils.rangeCopyToClipboard(gridItems, currentState),
},
{
type: 'button',
text: '選択行を削除',
icon: 'ti ti-trash', icon: 'ti ti-trash',
action: () => onRowDeleting(currentState.rangedRows), action: () => optInGridUtils.deleteSelectionRange(gridItems, currentState),
},
);
}
function onGridCellContextMenu(event: GridCellContextMenuEvent, currentState: GridCurrentState) {
event.menuItems.push(
{
type: 'button',
text: '選択範囲をコピー',
icon: 'ti ti-copy',
action: () => optInGridUtils.rangeCopyToClipboard(gridItems, currentState),
},
{
type: 'button',
text: '選択行を削除',
icon: 'ti ti-trash',
action: () => optInGridUtils.deleteSelectionRange(gridItems, currentState),
}, },
); );
} }
function onGridCellValueChange(event: GridCellValueChangeEvent, currentState: GridCurrentState) { function onGridCellValueChange(event: GridCellValueChangeEvent, currentState: GridCurrentState) {
gridItems.value[event.row.index][event.column.setting.bindTo] = event.newValue; optInGridUtils.applyCellValueFromEvent(gridItems, event);
} }
function onGridKeyDown(event: GridKeyDownEvent, currentState: GridCurrentState) { function onGridKeyDown(event: GridKeyDownEvent, currentState: GridCurrentState) {
switch (event.event.code) { optInGridUtils.commonKeyDownHandler(gridItems, event, currentState);
case 'KeyC': {
rangeCopyToClipboard(currentState);
break;
}
case 'KeyV': {
pasteFromClipboard(currentState);
break;
}
case 'Delete': {
if (currentState.rangedRows.length > 0) {
onRowDeleting(currentState.rangedRows);
} 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;
}
}
}
async function eachDroppedItems(itemList: DataTransferItemList): Promise<DroppedItem[]> {
async function readEntry(entry: FileSystemEntry): Promise<DroppedItem> {
if (entry.isFile) {
return {
isFile: true,
path: entry.fullPath,
file: await readFile(entry as FileSystemFileEntry),
};
} else {
return {
isFile: false,
path: entry.fullPath,
children: await readDirectory(entry as FileSystemDirectoryEntry),
};
}
}
function readFile(fileSystemFileEntry: FileSystemFileEntry): Promise<File> {
return new Promise((resolve, reject) => {
fileSystemFileEntry.file(resolve, reject);
});
}
function readDirectory(fileSystemDirectoryEntry: FileSystemDirectoryEntry): Promise<DroppedItem[]> {
return new Promise((resolve, reject) => {
fileSystemDirectoryEntry.createReader().readEntries(
async (entries) => resolve(await Promise.all(entries.map(readEntry))),
reject,
);
});
}
//
const items = Array.of<DataTransferItem>();
for (let i = 0; i < itemList.length; i++) {
items.push(itemList[i]);
}
return Promise.all(
items
.map(it => it.webkitGetAsEntry())
.filter(it => it)
.map(it => readEntry(it!)),
);
}
function flattenDroppedItems(items: DroppedItem[]): DroppedFile[] {
const result = Array.of<DroppedFile>();
for (const item of items) {
if (item.isFile) {
result.push(item);
} else {
result.push(...flattenDroppedItems(item.children));
}
}
return result;
}
function rangeCopyToClipboard(currentState: GridCurrentState) {
const lines = Array.of<string>();
const bounds = currentState.randedBounds;
for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) {
const items = Array.of<string>();
for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) {
const cell = gridItems.value[row][col];
items.push(cell.value?.toString() ?? '');
}
lines.push(items.join('\t'));
}
const text = lines.join('\n');
copyToClipboard(text);
}
async function pasteFromClipboard(currentState: GridCurrentState) {
function parseValue(value: string, type: ColumnSetting['type']): CellValue {
switch (type) {
case 'number': {
return Number(value);
}
case 'boolean': {
return value === 'true';
}
default: {
return value;
}
}
}
const cells = currentState.rangedCells;
const clipBoardText = await navigator.clipboard.readText();
const bounds = currentState.randedBounds;
const lines = clipBoardText.replace(/\r/g, '')
.split('\n')
.map(it => it.split('\t'));
if (lines.length === 1 && lines[0].length === 1) {
//
const ranges = currentState.rangedCells;
for (const cell of ranges) {
gridItems.value[cell.row.index][cell.column.setting.bindTo] = parseValue(lines[0][0], cell.column.setting.type);
}
} else {
//
const offsetRow = bounds.leftTop.row;
const offsetCol = bounds.leftTop.col;
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][col] = parseValue(items[colIdx], cells[row][col].column.setting.type);
}
}
}
} }
async function refreshUploadFolders() { async function refreshUploadFolders() {

View File

@ -0,0 +1,114 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export type DroppedItem = DroppedFile | DroppedDirectory;
export type DroppedFile = {
isFile: true;
path: string;
file: File;
};
export type DroppedDirectory = {
isFile: false;
path: string;
children: DroppedItem[];
}
export async function extractDroppedItems(ev: DragEvent): Promise<DroppedItem[]> {
const dropItems = ev.dataTransfer?.items;
if (!dropItems || dropItems.length === 0) {
return [];
}
const apiTestItem = dropItems[0];
if ('webkitGetAsEntry' in apiTestItem) {
return readDataTransferItems(dropItems);
} else {
// webkitGetAsEntryに対応していない場合はfilesから取得するディレクトリのサポートは出来ない
const dropFiles = ev.dataTransfer.files;
if (dropFiles.length === 0) {
return [];
}
const droppedFiles = Array.of<DroppedFile>();
for (let i = 0; i < dropFiles.length; i++) {
const file = dropFiles.item(i);
if (file) {
droppedFiles.push({
isFile: true,
path: file.name,
file,
});
}
}
return droppedFiles;
}
}
/**
* {@link File}
*/
export async function readDataTransferItems(itemList: DataTransferItemList): Promise<DroppedItem[]> {
async function readEntry(entry: FileSystemEntry): Promise<DroppedItem> {
if (entry.isFile) {
return {
isFile: true,
path: entry.fullPath,
file: await readFile(entry as FileSystemFileEntry),
};
} else {
return {
isFile: false,
path: entry.fullPath,
children: await readDirectory(entry as FileSystemDirectoryEntry),
};
}
}
function readFile(fileSystemFileEntry: FileSystemFileEntry): Promise<File> {
return new Promise((resolve, reject) => {
fileSystemFileEntry.file(resolve, reject);
});
}
function readDirectory(fileSystemDirectoryEntry: FileSystemDirectoryEntry): Promise<DroppedItem[]> {
return new Promise((resolve, reject) => {
fileSystemDirectoryEntry.createReader().readEntries(
async (entries) => resolve(await Promise.all(entries.map(readEntry))),
reject,
);
});
}
// 扱いにくいので配列に変換
const items = Array.of<DataTransferItem>();
for (let i = 0; i < itemList.length; i++) {
items.push(itemList[i]);
}
return Promise.all(
items
.map(it => it.webkitGetAsEntry())
.filter(it => it)
.map(it => readEntry(it!)),
);
}
/**
* {@link DroppedItem}
*/
export function flattenDroppedFiles(items: DroppedItem[]): DroppedFile[] {
const result = Array.of<DroppedFile>();
for (const item of items) {
if (item.isFile) {
result.push(item);
} else {
result.push(...flattenDroppedFiles(item.children));
}
}
return result;
}