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 * as os from '@/os.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';
const emit = defineEmits<{

View File

@ -43,7 +43,7 @@ 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 } 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 * as os from '@/os.js';
import { GridCurrentState, GridEvent } from '@/components/grid/grid-event.js';
@ -774,6 +774,10 @@ function emitCellValue(sender: GridCell | CellAddress, newValue: CellValue) {
oldValue: cell.value,
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 { 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';

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';
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>
<ul>
<li>この枠に画像ファイルまたはディレクトリをドラッグドロップ</li>
<li><a @click="onFileSelectClicked">このリンクをクリックしてPCから選択する</a></li>
<li><a @click="onDriveSelectClicked">このリンクをクリックしてドライブから選択する</a></li>
<li><a @click.prevent="onFileSelectClicked">このリンクをクリックしてPCから選択する</a></li>
<li><a @click.prevent="onDriveSelectClicked">このリンクをクリックしてドライブから選択する</a></li>
</ul>
</div>
@ -88,7 +88,7 @@
<script setup lang="ts">
/* 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 { fromDriveFile, GridItem } from '@/pages/admin/custom-emojis-grid.impl.js';
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 { uploadFile } from '@/scripts/upload.js';
import {
GridCellContextMenuEvent,
GridCellValidationEvent,
GridCellValueChangeEvent,
GridCurrentState,
@ -113,7 +114,8 @@ import {
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { CellValue } from '@/components/grid/cell.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 = {
id?: string;
@ -134,20 +136,6 @@ type RegisterLogItem = {
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[] = [
{ 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] },
@ -248,35 +236,7 @@ async function onClearClicked() {
}
async function onDrop(ev: DragEvent) {
const dropItems = ev.dataTransfer?.items;
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 droppedFiles = await extractDroppedItems(ev).then(it => flattenDroppedFiles(it));
const uploadedItems = await Promise.all(
droppedFiles.map(async (it) => ({
droppedFile: it,
@ -303,7 +263,6 @@ async function onDrop(ev: DragEvent) {
}
async function onFileSelectClicked(ev: MouseEvent) {
ev.preventDefault();
const driveFiles = await chooseFileFromPc(
true,
{
@ -317,16 +276,10 @@ async function onFileSelectClicked(ev: MouseEvent) {
}
async function onDriveSelectClicked(ev: MouseEvent) {
ev.preventDefault();
const driveFiles = await chooseFileFromDrive(true);
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) {
switch (event.type) {
case 'cell-validation':
@ -335,6 +288,9 @@ function onGridEvent(event: GridEvent, currentState: GridCurrentState) {
case 'row-context-menu':
onGridRowContextMenu(event, currentState);
break;
case 'cell-context-menu':
onGridCellContextMenu(event, currentState);
break;
case 'cell-value-change':
onGridCellValueChange(event, currentState);
break;
@ -352,170 +308,42 @@ function onGridRowContextMenu(event: GridRowContextMenuEvent, currentState: Grid
event.menuItems.push(
{
type: 'button',
text: '行を削除',
text: '選択行をコピー',
icon: 'ti ti-copy',
action: () => optInGridUtils.rangeCopyToClipboard(gridItems, currentState),
},
{
type: 'button',
text: '選択行を削除',
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) {
gridItems.value[event.row.index][event.column.setting.bindTo] = event.newValue;
optInGridUtils.applyCellValueFromEvent(gridItems, event);
}
function onGridKeyDown(event: GridKeyDownEvent, currentState: GridCurrentState) {
switch (event.event.code) {
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);
}
}
}
optInGridUtils.commonKeyDownHandler(gridItems, event, currentState);
}
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;
}