@animech-public/chonky
Version:
A File Browser component for React
407 lines (397 loc) • 13.8 kB
text/typescript
import { reduxActions } from '../redux/reducers';
import {
getFileData,
getIsFileSelected,
selectDisableSelection,
selectDisableSimpleDeselection,
selectRenamingFileId,
selectors,
selectParentFolder,
selectSelectionSize,
} from '../redux/selectors';
import { reduxThunks } from '../redux/thunks';
import { thunkRequestFileAction } from '../redux/thunks/dispatchers.thunks';
import {
ChangeSelectionPayload,
EndDragNDropPayload,
KeyboardClickFilePayload,
MouseClickFilePayload,
MoveFilesPayload,
OpenFileContextMenuPayload,
OpenFilesPayload,
RenameFilePayload,
StartDragNDropPayload,
StartRenamingFilePayload,
EndRenamingFilePayload,
} from '../types/action-payloads.types';
import { ChonkyIconName } from '../types/icons.types';
import { FileHelper } from '../util/file-helper';
import { defineFileAction } from '../util/helpers';
import { Logger } from '../util/logger';
import { ChonkyActions } from './index';
export const EssentialActions = {
/**
* Action that is dispatched when the user clicks on a file entry using their mouse.
* Both single clicks and double clicks trigger this action.
*/
MouseClickFile: defineFileAction(
{
id: 'mouse_click_file',
__payloadType: {} as MouseClickFilePayload,
} as const,
({ payload, reduxDispatch, getReduxState }) => {
const { file, fileDisplayIndex, clickType, ctrlKey, shiftKey, targetElement } = payload as MouseClickFilePayload;
const fileId = file.id;
if (clickType === 'double') {
if (FileHelper.isOpenable(file)) {
reduxDispatch(
thunkRequestFileAction(ChonkyActions.OpenFiles, {
targetFile: file,
// To simulate Windows Explorer and Nautilus behaviour,
// a double click on a file only opens that file even if
// there is a selection.
files: [file],
}),
);
}
} else {
// We're dealing with a single click
const state = getReduxState();
const disableSimpleDeselection = selectDisableSimpleDeselection(state);
const disableSelection = selectDisableSelection(state);
const selectionSize = selectSelectionSize(state);
const isFileSelected = getIsFileSelected(state, file);
const lastClick = selectors.getLastClick(state);
if (
!ctrlKey &&
!shiftKey &&
FileHelper.isRenamable(file) &&
targetElement instanceof HTMLElement &&
targetElement.dataset.chonkyFileEntryName && // Check if the click target is the file entry name
(disableSelection ? lastClick?.fileId === fileId : isFileSelected && selectionSize === 1)
) {
reduxDispatch(
thunkRequestFileAction(EssentialActions.StartRenamingFile, {
fileId,
}),
);
} else if (FileHelper.isSelectable(file) && !disableSelection) {
if (ctrlKey) {
// Multiple selection
reduxDispatch(
reduxActions.selectFile({
fileId: fileId,
exclusive: false,
toggle: true,
}),
);
reduxDispatch(
reduxActions.setLastClickIndex({
index: fileDisplayIndex,
fileId: fileId,
}),
);
} else if (shiftKey) {
// Range selection
const lastClickIndex = lastClick?.index;
if (typeof lastClickIndex === 'number') {
// We have the index of the previous click
let rangeStart = lastClickIndex;
let rangeEnd = fileDisplayIndex;
if (rangeStart > rangeEnd) {
[rangeStart, rangeEnd] = [rangeEnd, rangeStart];
}
reduxDispatch(
reduxThunks.selectRange({
rangeStart,
rangeEnd,
}),
);
reduxDispatch(
reduxActions.setLastClickIndex({
index: fileDisplayIndex,
fileId: fileId,
}),
);
} else {
// Since we can't do a range selection, do a
// multiple selection
reduxDispatch(
reduxActions.selectFile({
fileId: fileId,
exclusive: false,
toggle: !disableSimpleDeselection,
}),
);
reduxDispatch(
reduxActions.setLastClickIndex({
index: fileDisplayIndex,
fileId: fileId,
}),
);
}
} else {
// Exclusive selection
reduxDispatch(
reduxActions.selectFile({
fileId: fileId,
exclusive: true,
toggle: !disableSimpleDeselection,
}),
);
reduxDispatch(
reduxActions.setLastClickIndex({
index: fileDisplayIndex,
fileId: fileId,
}),
);
}
} else {
if (!ctrlKey && !disableSelection) {
reduxDispatch(reduxActions.clearSelection());
}
reduxDispatch(
reduxActions.setLastClickIndex({
index: fileDisplayIndex,
fileId: fileId,
}),
);
}
}
},
),
/**
* Action that is dispatched when the user "clicks" on a file using their keyboard.
* Using Space and Enter keys counts as clicking.
*/
KeyboardClickFile: defineFileAction(
{
id: 'keyboard_click_file',
__payloadType: {} as KeyboardClickFilePayload,
} as const,
({ payload, reduxDispatch, getReduxState }) => {
reduxDispatch(
reduxActions.setLastClickIndex({
index: payload.fileDisplayIndex,
fileId: payload.file.id,
}),
);
if (payload.enterKey) {
// We only dispatch the Open Files action here when the selection is
// empty. Otherwise, `Enter` key presses are handled by the
// hotkey manager for the Open Files action.
if (selectSelectionSize(getReduxState()) === 0) {
reduxDispatch(
thunkRequestFileAction(ChonkyActions.OpenFiles, {
targetFile: payload.file,
files: [payload.file],
}),
);
}
}
},
),
/**
* Action that is dispatched when user starts dragging some file.
*/
StartDragNDrop: defineFileAction(
{
id: 'start_drag_n_drop',
__payloadType: {} as StartDragNDropPayload,
} as const,
({ payload, reduxDispatch, getReduxState }) => {
const file = payload.draggedFile;
if (!getIsFileSelected(getReduxState(), file)) {
if (FileHelper.isSelectable(file)) {
reduxDispatch(
reduxActions.selectFiles({
fileIds: [file.id],
reset: true,
}),
);
}
}
},
),
/**
* Action that is dispatched when user either cancels the drag & drop interaction,
* or drops a file somewhere.
*/
EndDragNDrop: defineFileAction(
{
id: 'end_drag_n_drop',
__payloadType: {} as EndDragNDropPayload,
} as const,
({ payload, reduxDispatch, getReduxState }) => {
if (getIsFileSelected(getReduxState(), payload.destination)) {
// Can't drop a selection into itself
return;
}
const { draggedFile, selectedFiles } = payload as EndDragNDropPayload;
const droppedFiles = selectedFiles.length > 0 ? selectedFiles : [draggedFile];
reduxDispatch(
thunkRequestFileAction(ChonkyActions.MoveFiles, {
...payload,
files: droppedFiles,
}),
);
},
),
/**
* Action that is dispatched when user moves files from one folder to another,
* usually by dragging & dropping some files into the folder.
*/
MoveFiles: defineFileAction({
id: 'move_files',
__payloadType: {} as MoveFilesPayload,
} as const),
/**
* Action that is dispatched after user finishes renaming a file,
* usually by pressing Enter or the input field loses focus.
*/
RenameFile: defineFileAction({
id: 'rename_file',
__payloadType: {} as RenameFilePayload,
} as const),
/**
* Action that is dispatched when user starts renaming a file,
* usually by clicking on the file name of a single selected file.
*/
StartRenamingFile: defineFileAction(
{
id: 'start_renaming_file',
__payloadType: {} as StartRenamingFilePayload,
} as const,
({ payload, reduxDispatch, getReduxState }) => {
const { fileId } = payload as StartRenamingFilePayload;
const file = getFileData(getReduxState(), fileId);
if (FileHelper.isRenamable(file)) {
reduxDispatch(reduxActions.startRenaming(fileId));
} else {
Logger.warn(
`Start renaming file action was triggered for file that is ` +
`not renamable. This may indicate a bug in internal components.`,
);
}
},
),
/**
* Action that is dispatched when user either cancels the renaming,
* or save the new target name.
*/
EndRenamingFile: defineFileAction(
{
id: 'end_renaming_file',
__payloadType: {} as EndRenamingFilePayload,
} as const,
({ payload, reduxDispatch, getReduxState }) => {
const renamingFileId = selectRenamingFileId(getReduxState());
if (renamingFileId) {
const { targetName } = payload as EndRenamingFilePayload;
if (targetName !== undefined) {
const file = getFileData(getReduxState(), renamingFileId);
if (FileHelper.isRenamable(file) && file.name !== targetName) {
reduxDispatch(
thunkRequestFileAction(ChonkyActions.RenameFile, {
file: file,
targetName: targetName,
}),
);
}
}
reduxDispatch(reduxActions.endRenaming());
}
},
),
/**
* Action that is dispatched when the selection changes for any reason.
*/
ChangeSelection: defineFileAction({
id: 'change_selection',
__payloadType: {} as ChangeSelectionPayload,
} as const),
/**
* Action that is dispatched when user wants to open some files. This action is
* often triggered by other actions.
*/
OpenFiles: defineFileAction({
id: 'open_files',
__payloadType: {} as OpenFilesPayload,
} as const),
/**
* Action that is triggered when user wants to go up a directory.
*/
OpenParentFolder: defineFileAction(
{
id: 'open_parent_folder',
button: {
name: 'Go up a directory',
toolbar: true,
contextMenu: false,
icon: ChonkyIconName.openParentFolder,
iconOnly: true,
},
} as const,
({ reduxDispatch, getReduxState }) => {
const reduxState = getReduxState();
const parentFolder = selectParentFolder(reduxState);
if (FileHelper.isOpenable(parentFolder)) {
reduxDispatch(
thunkRequestFileAction(ChonkyActions.OpenFiles, {
targetFile: parentFolder,
files: [parentFolder],
}),
);
} else if (!reduxState.forceEnableOpenParent) {
Logger.warn(
'Open parent folder effect was triggered even though the parent folder' +
' is not openable. This indicates a bug in presentation components.',
);
}
},
),
/**
* Action that is dispatched when user opens the context menu, either by right click
* on something or using the context menu button on their keyboard.
*/
OpenFileContextMenu: defineFileAction(
{
id: 'open_file_context_menu',
__payloadType: {} as OpenFileContextMenuPayload,
} as const,
({ payload, reduxDispatch, getReduxState }) => {
// TODO: Check if the context menu component is actually enabled. There is a
// chance it doesn't matter if it is enabled or not - if it is not mounted,
// the action will simply have no effect. It also allows users to provide
// their own components - however, users could also flip the "context menu
// component mounted" switch...
const triggerFile = getFileData(getReduxState(), payload.triggerFileId);
if (triggerFile) {
const fileSelected = getIsFileSelected(getReduxState(), triggerFile);
if (!fileSelected) {
// If file is selected, we leave the selection as is. If it is not
// selected, it means user right clicked the file with no selection.
// We simulate the Windows Explorer/Nautilus behaviour of moving
// selection to this file.
if (FileHelper.isSelectable(triggerFile)) {
reduxDispatch(
reduxActions.selectFiles({
fileIds: [payload.triggerFileId],
reset: true,
}),
);
} else {
reduxDispatch(reduxActions.clearSelection());
}
}
}
reduxDispatch(
reduxActions.showContextMenu({
triggerFileId: payload.triggerFileId,
mouseX: payload.clientX - 2,
mouseY: payload.clientY - 4,
}),
);
},
),
};