Skip to content

Add Keyboard Shortcuts System #2365

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .storybook/preview.js
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@ import getStore from '../src/bundles/index.js'
import i18n from '../src/i18n.js'
import DndBackend from '../src/lib/dnd-backend.js'
import { HeliaProvider, ExploreProvider } from 'ipld-explorer-components/providers'

import { ShortcutsProvider } from '../src/contexts/ShortcutsContext.js'
/**
* @type {import('@storybook/addons').BaseAnnotations}
*/
@@ -21,7 +21,9 @@ const baseAnnotations = {
<DndProvider backend={DndBackend}>
<HeliaProvider>
<ExploreProvider>
<Story />
<ShortcutsProvider>
<Story />
</ShortcutsProvider>
</ExploreProvider>
</HeliaProvider>
</DndProvider>
19 changes: 16 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -138,6 +138,8 @@
"@types/jest": "^29.5.14",
"@types/node": "^14.18.36",
"@types/path-browserify": "^1.0.0",
"@types/prop-types": "^15.7.14",
"@types/react-overlays": "^3.1.0",
"@typescript-eslint/parser": "^5.62.0",
"aegir": "^42.2.2",
"autoprefixer": "^10.4.7",
8 changes: 8 additions & 0 deletions public/locales/en/app.json
Original file line number Diff line number Diff line change
@@ -105,5 +105,13 @@
"skip": "Skip",
"tooltip": "Click this button any time for a guided tour on the current page."
},
"shortcutModal": {
"title": "Keyboard Shortcuts",
"description": "The following keyboard shortcuts are available in the Files section:",
"showShortcuts": "Show keyboard shortcuts",
"general": "General",
"tourHelp": "Show tour help",
"ipfsPath": "Enter QmHash or CID"
},
"startTourHelper": "Start tour"
}
43 changes: 20 additions & 23 deletions public/locales/en/files.json
Original file line number Diff line number Diff line change
@@ -49,28 +49,6 @@
"checkboxRemoveLocalPin": "Also remove local pin (recommended)",
"checkboxUnpinFromServices": "Unpin from all pinning services"
},
"shortcutModal": {
"title": "Keyboard Shortcuts",
"description": "The following keyboard shortcuts are available in the Files section:",
"navigation": "Navigation",
"selection": "Selection",
"actions": "Actions",
"other": "Other",
"moveDown": "Move down",
"moveUp": "Move up",
"moveLeft": "Move left",
"moveRight": "Move right",
"navigate": "Navigate to selected item",
"rename": "Rename selected item",
"delete": "Delete selected item(s)",
"toggleSelection": "Toggle selection",
"selectAll": "Select all items",
"deselectAll": "Deselect all items",
"copy": "Copy selected item(s)",
"paste": "Paste item(s)",
"cut": "Cut selected item(s)",
"showShortcuts": "Show keyboard shortcuts"
},
"pinningModal": {
"title": "Select where you would like to pin these items.",
"complianceLabel": "🔍 Check pinning services' compliance",
@@ -185,5 +163,24 @@
},
"noPinsInProgress": "All done, no remote pins in progress.",
"remotePinningInProgress": "Remote pinning in progress:",
"selectAllEntries": "Select all entries"
"selectAllEntries": "Select all entries",
"shortcutModal": {
"navigation": "Navigation",
"selection": "Selection",
"actions": "Actions",
"other": "Other",
"moveDown": "Move down",
"moveUp": "Move up",
"moveLeft": "Move left",
"moveRight": "Move right",
"navigate": "Navigate to selected item",
"rename": "Rename selected item",
"delete": "Delete selected item(s)",
"toggleSelection": "Toggle selection",
"selectAll": "Select all items",
"deselectAll": "Deselect all items",
"copy": "Copy selected item(s)",
"paste": "Paste item(s)",
"cut": "Cut selected item(s)"
}
}
1 change: 1 addition & 0 deletions public/locales/en/peers.json
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@
"localNetwork": "Local Network",
"nearby": "nearby",
"protocols": "Open streams",
"filterPeers": "Filter peers",
"addConnection": "Add connection",
"insertPeerAddress": "Insert the peer address you want to connect to.",
"addPermanentPeer": "Add to the permanent peering configuration",
14 changes: 10 additions & 4 deletions src/components/modal/Modal.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React from 'react'
// @ts-nocheck
import * as React from 'react'
import PropTypes from 'prop-types'
import CancelIcon from '../../icons/GlyphSmallCancel.js'
import CancelIcon from '../../icons/GlyphSmallCancel'

// @ts-ignore
export const ModalActions = ({ justify = 'between', className = '', children, ...props }) => (
<div className={`flex justify-${justify} pa2 ${className}`} style={{ backgroundColor: '#f4f6f8' }} {...props}>
{ children }
@@ -10,9 +12,11 @@ export const ModalActions = ({ justify = 'between', className = '', children, ..

ModalActions.propTypes = {
justify: PropTypes.string,
className: PropTypes.string
className: PropTypes.string,
children: PropTypes.node
}

// @ts-ignore
export const ModalBody = ({ className = '', Icon, title, children, ...props }) => (
<div className={`ph4 pv3 tc ${className}`} {...props}>
{ Icon && (
@@ -32,9 +36,11 @@ ModalBody.propTypes = {
title: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node
])
]),
children: PropTypes.node
}

// @ts-ignore
export const Modal = ({ onCancel, children, className, ...props }) => {
return (
<div className={`${className} bg-white w-80 shadow-4 sans-serif relative`} style={{ maxWidth: '34em' }} {...props}>
2 changes: 1 addition & 1 deletion src/components/notify/Toast.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react'
import CancelIcon from '../../icons/GlyphSmallCancel.js'
import CancelIcon from '../../icons/GlyphSmallCancel'

const Toast = ({ error, children, onDismiss }) => {
const bg = error ? 'bg-yellow' : 'bg-green'
41 changes: 0 additions & 41 deletions src/components/overlay/Overlay.js

This file was deleted.

41 changes: 41 additions & 0 deletions src/components/overlay/Overlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React, { ReactElement } from 'react'
import { Modal } from 'react-overlays'

interface OverlayProps {
children: React.ReactNode
show: boolean
onLeave: () => void
className: string
hidden: boolean
}

const Overlay: React.FC<OverlayProps> = ({ children, show, onLeave, className, hidden, ...props }): ReactElement<any> => {
const handleKeyDown = (e: React.KeyboardEvent): null | void => {
if (e.key !== 'Escape') return null

e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()

onLeave()
}

const renderBackdrop = (): React.ReactNode => {
return (
<div className='fixed top-0 left-0 right-0 bottom-0 bg-black o-50' hidden={hidden} {...props}></div>
)
}

return (
<Modal
{...props}
show={show}
className={`${className} fixed top-0 left-0 right-0 bottom-0 z-max flex items-center justify-around`}
renderBackdrop={renderBackdrop}
onKeyDown={handleKeyDown}
onBackdropClick={onLeave}>
{children}
</Modal>
)
}

export default Overlay
2 changes: 1 addition & 1 deletion src/components/tour/TourHelper.js
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ export const TourHelper = ({ doEnableTours, className = '', size = 23, t }) => {
}

return (
<button className={`dib mr1 ml4-m pointer ${className}`} onClick={handleClick} aria-label={ t('startTourHelper')}>
<button id='tour-helper' className={`dib mr1 ml4-m pointer ${className}`} onClick={handleClick} aria-label={ t('startTourHelper')}>
<svg className='fill-teal o-60 glow' viewBox='0 0 44 44' width={size} height={size} aria-hidden="true">
<path d='m22,0c-12.2,0-22,9.8-22,22s9.8,22 22,22 22-9.8 22-22-9.8-22-22-22zm2,34c0,0.6-0.4,1-1,1h-2c-0.6,0-1-0.4-1-1v-2c0-0.6 0.4-1 1-1h2c0.6,0 1,0.4 1,1v2zm2.7-8.9c-1.4,1.2-2.4,2-2.7,3.1-0.1,0.5-0.5,0.8-1,0.8h-2c-0.6,0-1.1-0.5-1-1.1 0.4-2.9 2.5-4.5 4.2-5.9 1.8-1.4 2.8-2.3 2.8-4 0-2.8-2.2-5-5-5s-5,2.2-5,5c0,0.2 0,0.4 0,0.6 0.1,0.5-0.2,1-0.7,1.1l-1.9,.6c-0.6,0.2-1.2-0.2-1.3-0.8-0.1-0.5-0.1-1-0.1-1.5 0-5 4-9 9-9s9,4 9,9c0,3.7-2.4,5.6-4.3,7.1z' />
</svg>
151 changes: 151 additions & 0 deletions src/contexts/ShortcutsContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import React, { createContext, useContext, useCallback, useEffect } from 'react'
import ShortcutModal from '../files/modals/shortcut-modal/shortcut-modal'
// @ts-ignore
import Overlay from '../components/overlay/Overlay.js'
import { t } from 'i18next'

interface Shortcut {
keys: string[]
label: string
hidden?: boolean
action: () => void
group?: string
}

interface ShortcutsContextType {
shortcuts: Shortcut[]
updateShortcuts: (newShortcuts: Shortcut[]) => void
}

const ShortcutsContext = createContext<ShortcutsContextType | null>(null)

export const ShortcutsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [showShortcuts, setShowShortcuts] = React.useState(false)
const defaultShortcut: Shortcut[] = [
{
keys: ['Shift', 'H'],
label: t('app:shortcutModal.tourHelp'),
action: () => {
const tourHelper = document.getElementById('tour-helper')
if (tourHelper) {
tourHelper.click()
}
},
group: t('app:shortcutModal.general')
},
{
keys: ['/'],
label: t('app:shortcutModal.ipfsPath'),
action: () => {
const ipfsPath = document.getElementById('ipfs-path')
if (ipfsPath) {
ipfsPath.focus()
}
},
group: t('app:shortcutModal.general')
},
{
keys: ['Shift', '?'],
label: t('app:shortcutModal.showShortcuts'),
action: () => {
setShowShortcuts(prev => !prev)
},
group: t('app:shortcutModal.general')
}
]
const [shortcuts, setShortcuts] = React.useState<Shortcut[]>(defaultShortcut)
const [allowUpdate, setAllowUpdate] = React.useState(true)

const closeModal = () => {
setShowShortcuts(false)
setAllowUpdate(true)
}

const isPressed = (keys: string[], e: KeyboardEvent) => {
return keys.every(key => {
switch (key.toLowerCase()) {
case 'shift':
return e.shiftKey
case 'ctrl':
return e.ctrlKey
case 'alt':
return e.altKey
case 'meta':
return e.metaKey
case 'space':
return e.key === ' '
default:
return e.key === key
}
})
}

const hash = window.location.hash

useEffect(() => {
if (allowUpdate) setShortcuts(defaultShortcut)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hash])

const handleKeyDown = useCallback((e: KeyboardEvent) => {
const target = e.target as HTMLElement
if ((target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT') &&
target.closest('.modal')) return

if ((document.activeElement?.tagName === 'INPUT' &&
(document.activeElement as HTMLInputElement).type !== 'checkbox') ||
document.activeElement?.tagName === 'TEXTAREA' ||
(document.activeElement as HTMLElement)?.isContentEditable) {
return
}

shortcuts.forEach(shortcut => {
if (isPressed(shortcut.keys, e)) {
e.preventDefault()
shortcut.action()
}
})
}, [shortcuts])

useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])

return (
<ShortcutsContext.Provider value={{
shortcuts,
updateShortcuts: (newShortcuts: Shortcut[]) => {
setAllowUpdate(false)
setShortcuts([...defaultShortcut, ...newShortcuts])
}
}}>
{children}
<div>
<Overlay show={showShortcuts} hidden={!showShortcuts} className='' onLeave={closeModal}>
<ShortcutModal
className='outline-0'
onLeave={closeModal} />
</Overlay>
</div>
</ShortcutsContext.Provider>
)
}

export const useShortcuts = (shortcuts?: Shortcut[]) => {
const context = useContext(ShortcutsContext)
if (!context) {
throw new Error('useShortcuts must be used within a ShortcutsProvider')
}

useEffect(() => {
if (shortcuts) {
context.updateShortcuts(shortcuts)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

return context.shortcuts
}
22 changes: 2 additions & 20 deletions src/files/FilesPage.js
Original file line number Diff line number Diff line change
@@ -12,13 +12,11 @@ import withTour from '../components/tour/withTour.js'
import InfoBoxes from './info-boxes/InfoBoxes.js'
import FilePreview from './file-preview/FilePreview.js'
import FilesList from './files-list/FilesList.js'
import FilesGrid from './files-grid/files-grid.js'
import FilesGrid from './files-grid/files-grid.tsx'
import { ViewList, ViewModule } from '../icons/stroke-icons.js'
import { getJoyrideLocales } from '../helpers/i8n.js'

// Icons
import Modals, { DELETE, NEW_FOLDER, SHARE, ADD_BY_CAR, RENAME, ADD_BY_PATH, BULK_CID_IMPORT, SHORTCUTS, CLI_TUTOR_MODE, PINNING, PUBLISH } from './modals/Modals.js'

import Modals, { DELETE, NEW_FOLDER, SHARE, ADD_BY_CAR, RENAME, ADD_BY_PATH, BULK_CID_IMPORT, CLI_TUTOR_MODE, PINNING, PUBLISH } from './modals/Modals.js'
import Header from './header/Header.js'
import FileImportStatus from './file-import-status/FileImportStatus.js'
import { useExplore } from 'ipld-explorer-components/providers'
@@ -56,22 +54,6 @@ const FilesPage = ({
}
}, [ipfsConnected, filesPathInfo, doFilesFetch])

useEffect(() => {
const handleKeyDown = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return
}

if (e.key === '?' && e.shiftKey) {
e.preventDefault()
showModal(SHORTCUTS)
}
}

document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])

/* TODO: uncomment below if we ever want automatic remote pin check
* (it was disabled for now due to https://github.com/ipfs/ipfs-desktop/issues/1954)
useEffect(() => {
154 changes: 122 additions & 32 deletions src/files/files-grid/files-grid.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { useRef, useState, useEffect, useCallback, type FC, type MouseEvent } from 'react'
import { useRef, useCallback, type FC, type MouseEvent, useState } from 'react'
import { Trans, withTranslation } from 'react-i18next'
import { useDrop } from 'react-dnd'
import { NativeTypes } from 'react-dnd-html5-backend'
import { ExtendedFile, FileStream, normalizeFiles } from '../../lib/files.js'
import GridFile from './grid-file.jsx'
import GridFile from './grid-file'
// @ts-expect-error - redux-bundler-react is not typed
import { connect } from 'redux-bundler-react'
import './files-grid.css'
import { TFunction } from 'i18next'
import type { ContextMenuFile } from 'src/files/types.js'
import type { CID } from 'multiformats/cid'
import { useShortcuts } from '../../contexts/ShortcutsContext'

export interface FilesGridProps {
files: ContextMenuFile[]
@@ -41,22 +42,15 @@ const FilesGrid = ({
files, pins = [], remotePins = [], pendingPins = [], failedPins = [], filesPathInfo, t, onRemove, onRename, onNavigate, onAddFiles,
onMove, handleContextMenuClick, filesIsFetching, onSetPinning, onDismissFailedPin, selected = [], onSelect
}: FilesGridPropsConnected) => {
const [focused, setFocused] = useState<string | null>(null)
const [focusedState, setFocusedState] = useState<string | null>(null)
const focused = useRef<string | null>(null)
const filesRefs = useRef<Record<string, HTMLDivElement>>({})
const gridRef = useRef<HTMLDivElement | null>(null)

const [{ isOver, canDrop }, drop] = useDrop({
accept: NativeTypes.FILE,
drop: (_, monitor) => {
if (monitor.didDrop()) return
const { filesPromise } = monitor.getItem()
addFiles(filesPromise, onAddFiles)
},
collect: (monitor) => ({
isOver: monitor.isOver({ shallow: true }),
canDrop: filesPathInfo?.isMfs
})
})
const updateFocused = (name: string | null) => {
setFocusedState(name)
focused.current = name
}

const addFiles = async (filesPromise: Promise<ExtendedFile[]>, onAddFiles: (files: FileStream[]) => void) => {
const files = await filesPromise
@@ -67,18 +61,18 @@ const FilesGrid = ({
onSelect(fileName, isSelected)
}, [onSelect])

const keyHandler = useCallback((e: KeyboardEvent) => {
const focusedFile = focused == null ? null : files.find(el => el.name === focused)
const keyHandler = (e: KeyboardEvent) => {
if (filesIsFetching) return

gridRef.current?.focus?.()
const focusedFile = focused.current == null ? null : files.find(el => el.name === focused.current)

if (e.key === 'Escape') {
onSelect([], false)
setFocused(null)
updateFocused(null)
return
}

if ((e.key === 'F2') && focusedFile != null) {
if (e.key === 'F2' && focusedFile != null) {
return onRename([focusedFile])
}

@@ -88,7 +82,6 @@ const FilesGrid = ({
}

if ((e.key === ' ') && focusedFile != null) {
e.preventDefault()
handleSelect(focusedFile.name, !selected.includes(focusedFile.name))
return
}
@@ -100,7 +93,6 @@ const FilesGrid = ({
const isArrowKey = ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)

if (isArrowKey) {
e.preventDefault()
const columns = Math.floor((gridRef.current?.clientWidth || window.innerWidth) / 220)
const currentIndex = files.findIndex(el => el.name === focusedFile?.name)
let newIndex = currentIndex
@@ -140,7 +132,7 @@ const FilesGrid = ({

if (newIndex >= 0 && newIndex < files.length) {
const name = files[newIndex].name
setFocused(name)
updateFocused(name)
const element = filesRefs.current[name]
if (element && element.scrollIntoView) {
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
@@ -149,16 +141,114 @@ const FilesGrid = ({
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [files, focused])
}

useEffect(() => {
if (filesIsFetching) return
document.addEventListener('keydown', keyHandler)
return () => {
document.removeEventListener('keydown', keyHandler)
useShortcuts([{
keys: ['ArrowUp'],
label: t('shortcutModal.moveUp'),
group: t('shortcutModal.navigation'),
action: () => {
keyHandler({ key: 'ArrowUp' } as KeyboardEvent)
}
}, {
keys: ['ArrowDown'],
label: t('shortcutModal.moveDown'),
group: t('shortcutModal.navigation'),
action: () => {
keyHandler({ key: 'ArrowDown' } as KeyboardEvent)
}
}, {
keys: ['ArrowLeft'],
label: t('shortcutModal.moveLeft'),
group: t('shortcutModal.navigation'),
action: () => {
keyHandler({ key: 'ArrowLeft' } as KeyboardEvent)
}
}, {
keys: ['ArrowRight'],
label: t('shortcutModal.moveRight'),
group: t('shortcutModal.navigation'),
action: () => {
keyHandler({ key: 'ArrowRight' } as KeyboardEvent)
}
}, {
keys: ['F2'],
label: t('shortcutModal.rename'),
group: t('shortcutModal.actions'),
action: () => {
keyHandler({ key: 'F2' } as KeyboardEvent)
}
}, {
keys: ['Delete'],
label: t('shortcutModal.delete'),
group: t('shortcutModal.actions'),
action: () => {
keyHandler({ key: 'Delete' } as KeyboardEvent)
}
}, [keyHandler, filesIsFetching])
},
{
keys: ['Backspace'],
hidden: true,
label: t('shortcutModal.delete'),
group: t('shortcutModal.actions'),
action: () => {
keyHandler({ key: 'Backspace' } as KeyboardEvent)
}
},
{
keys: ['Space'],
label: t('shortcutModal.toggleSelection'),
group: t('shortcutModal.selection'),
action: () => {
keyHandler({ key: ' ' } as KeyboardEvent)
}
}, {
keys: ['Escape'],
label: t('shortcutModal.deselectAll'),
group: t('shortcutModal.selection'),
action: () => {
keyHandler({ key: 'Escape' } as KeyboardEvent)
}
}, {
keys: ['Enter'],
label: t('shortcutModal.navigate'),
group: t('shortcutModal.navigation'),
action: () => {
keyHandler({ key: 'Enter' } as KeyboardEvent)
}
},
{
keys: ['NumpadEnter'],
hidden: true,
label: t('shortcutModal.navigate'),
group: t('shortcutModal.navigation'),
action: () => {
keyHandler({ key: 'NumpadEnter' } as KeyboardEvent)
}
},
{
keys: ['ArrowRight', 'Meta'],
hidden: true,
label: t('shortcutModal.navigate'),
group: t('shortcutModal.navigation'),
action: () => {
keyHandler({ key: 'ArrowRight', metaKey: true } as KeyboardEvent)
}
}
])

const [{ isOver, canDrop }, drop] = useDrop({
accept: NativeTypes.FILE,
drop: (_, monitor) => {
if (monitor.didDrop()) return
const { filesPromise } = monitor.getItem()
addFiles(filesPromise, onAddFiles)
},
collect: (monitor) => ({
isOver: monitor.isOver({ shallow: true }),
canDrop: filesPathInfo?.isMfs
})
})

const gridClassName = `files-grid${isOver && canDrop ? ' files-grid--drop-target' : ''}`

@@ -173,7 +263,7 @@ const FilesGrid = ({
{...file}
refSetter={(r: HTMLDivElement | null) => { filesRefs.current[file.name] = r as HTMLDivElement }}
selected={selected.includes(file.name)}
focused={focused === file.name}
focused={focusedState === file.name}
pinned={pins?.includes(file.cid?.toString())}
isRemotePin={remotePins?.includes(file.cid?.toString())}
isPendingPin={pendingPins?.includes(file.cid?.toString())}
2 changes: 1 addition & 1 deletion src/files/files-grid/grid-file.tsx
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@ import { isBinary } from 'istextorbinary'
import FileIcon from '../file-icon/FileIcon.js'
// @ts-expect-error - redux-bundler-react is not typed
import { connect } from 'redux-bundler-react'
import FileThumbnail from '../file-preview/file-thumbnail.js'
import FileThumbnail from '../file-preview/file-thumbnail'
import PinIcon from '../pin-icon/PinIcon.js'
import GlyphDots from '../../icons/GlyphDots.js'
import Checkbox from '../../components/checkbox/Checkbox.js'
153 changes: 119 additions & 34 deletions src/files/files-list/FilesList.js
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@ import Checkbox from '../../components/checkbox/Checkbox.js'
// import SelectedActions from '../selected-actions/SelectedActions.js'
import File from '../file/File.js'
import LoadingAnimation from '../../components/loading-animation/LoadingAnimation.js'
import { useShortcuts } from '../../contexts/ShortcutsContext.js'

const addFiles = async (filesPromise, onAddFiles) => {
const files = await filesPromise
@@ -54,13 +55,19 @@ export const FilesList = ({
className = '', files, pins, pinningServices, remotePins = [], pendingPins = [], failedPins = [], filesSorting, updateSorting, filesIsFetching, filesPathInfo, showLoadingAnimation,
onShare, onSetPinning, selected, onSelect, onInspect, onDownload, onRemove, onRename, onNavigate, onRemotePinClick, onAddFiles, onMove, doFetchRemotePins, doDismissFailedPin, handleContextMenuClick, t
}) => {
const [focused, setFocused] = useState(null)
const [focusedState, setFocusedState] = useState(null)
const focused = useRef(null)
const [firstVisibleRow, setFirstVisibleRow] = useState(null)
const [allFiles, setAllFiles] = useState(mergeRemotePinsIntoFiles(files, remotePins, pendingPins, failedPins))
const listRef = useRef()
const filesRefs = useRef([])
const refreshPinCache = true

const updateFocused = (name) => {
setFocusedState(name)
focused.current = name
}

filesPathInfo = filesPathInfo ?? {}
const [{ canDrop, isOver, isDragging }, drop] = useDrop({
accept: NativeTypes.FILE,
@@ -93,7 +100,7 @@ export const FilesList = ({
}, [onSelect])

const keyHandler = useCallback((e) => {
const focusedFile = files.find(el => el.name === focused)
const focusedFile = files.find(el => el.name === focused.current)

// Disable keyboard controls if fetching files
if (filesIsFetching) {
@@ -102,53 +109,69 @@ export const FilesList = ({

if (e.key === 'Escape') {
onSelect([], false)
setFocused(null)
return listRef.current?.forceUpdateGrid?.()
updateFocused(null)
listRef.current?.forceUpdateGrid?.()
return
}

if (e.key === 'F2' && focused !== null) {
return onRename([focusedFile])
if (e.key === 'F2' && focused.current !== null) {
onRename([focusedFile])
return
}

if (e.key === 'Delete' && selected.length > 0) {
return onRemove(selectedFiles)
onRemove(selectedFiles)
return
}

if (e.key === ' ' && focused !== null) {
e.preventDefault()
return toggleOne(focused, true)
console.log('toggleOne', e.key, focused.current)

if (e.key === ' ' && focused.current !== null) {
toggleOne(focused.current, true)
return
}

if ((e.key === 'Enter' || (e.key === 'ArrowRight' && e.metaKey)) && focused !== null) {
return onNavigate({ path: focusedFile.path, cid: focusedFile.cid })
if ((e.key === 'Enter' || (e.key === 'ArrowRight' && e.metaKey)) && focused.current !== null) {
onNavigate({ path: focusedFile.path, cid: focusedFile.cid })
return
}

if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
let index = 0

if (focused !== null) {
const prev = files.findIndex(el => el.name === focused)
if (focused.current !== null) {
const prev = files.findIndex(el => el.name === focused.current)
index = (e.key === 'ArrowDown') ? prev + 1 : prev - 1
}

if (index === -1 || index >= files.length) {
return
if (index === -1) {
index = files.length - 1
}

let name = files[index].name
if (index >= files.length) {
index = 0
}

let name = files[index]?.name || null

// If the file we are going to focus is out of view (removed
// from the DOM by react-virtualized), focus the first visible file
if (!filesRefs.current[name]) {
name = files[firstVisibleRow].name
name = files[firstVisibleRow]?.name || null
}

setFocused(name)
updateFocused(name)
listRef.current?.forceUpdateGrid?.()

if (listRef.current && name !== null) {
const newIndex = files.findIndex(f => f.name === name)
if (newIndex !== -1) {
filesRefs.current[name].scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
}
}
}, [
files,
focused,
firstVisibleRow,
filesIsFetching,
onNavigate,
@@ -161,12 +184,75 @@ export const FilesList = ({
listRef
])

useEffect(() => {
document.addEventListener('keydown', keyHandler)
return () => {
document.removeEventListener('keydown', keyHandler)
useShortcuts([{
keys: ['ArrowUp'],
label: t('shortcutModal.moveUp'),
group: t('shortcutModal.navigation'),
action: () => {
keyHandler({ key: 'ArrowUp' })
}
}, {
keys: ['ArrowDown'],
label: t('shortcutModal.moveDown'),
group: t('shortcutModal.navigation'),
action: () => {
keyHandler({ key: 'ArrowDown' })
}
}, {
keys: ['F2'],
label: t('shortcutModal.rename'),
group: t('shortcutModal.actions'),
action: () => {
keyHandler({ key: 'F2' })
}
}, {
keys: ['Delete'],
label: t('shortcutModal.delete'),
group: t('shortcutModal.actions'),
action: () => {
keyHandler({ key: 'Delete' })
}
},
{
keys: ['Backspace'],
hidden: true,
label: t('shortcutModal.delete'),
group: t('shortcutModal.actions'),
action: () => {
keyHandler({ key: 'Backspace' })
}
}, [keyHandler])
},
{
keys: ['Space'],
label: t('shortcutModal.toggleSelection'),
group: t('shortcutModal.selection'),
action: () => {
keyHandler({ key: ' ' })
}
}, {
keys: ['Escape'],
label: t('shortcutModal.deselectAll'),
group: t('shortcutModal.selection'),
action: () => {
keyHandler({ key: 'Escape' })
}
}, {
keys: ['Enter'],
label: t('shortcutModal.navigate'),
group: t('shortcutModal.navigation'),
action: () => {
keyHandler({ key: 'Enter' })
}
},
{
keys: ['NumpadEnter'],
hidden: true,
label: t('shortcutModal.navigate'),
group: t('shortcutModal.navigation'),
action: () => {
keyHandler({ key: 'NumpadEnter' })
}
}])

useEffect(() => {
setAllFiles(mergeRemotePinsIntoFiles(files, remotePins, pendingPins, failedPins))
@@ -257,7 +343,7 @@ export const FilesList = ({
onSetPinning={onSetPinning}
onDismissFailedPin={onDismissFailedPinHandler}
onMove={move}
focused={focused === listItem.name}
focused={focusedState === listItem.name}
selected={selected.indexOf(listItem.name) !== -1}
handleContextMenuClick={handleContextMenuClick}
translucent={isDragging || (isOver && canDrop)} />
@@ -275,19 +361,17 @@ export const FilesList = ({
}, ['pl2 w2 glow'])

// Add a separate useEffect to handle scrolling when focus changes
const currentFilesRef = filesRefs.current[focused]
const currentFilesRef = filesRefs.current[focused.current]
useEffect(() => {
if (focused) {
if (focused.current && currentFilesRef) {
const domNode = currentFilesRef && findDOMNode(currentFilesRef)
if (domNode) {
domNode.scrollIntoView({ behavior: 'smooth', block: 'center' })
domNode.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
const checkbox = domNode.querySelector('input[type="checkbox"]')
if (checkbox) checkbox.focus()
}

listRef.current?.forceUpdateGrid?.()
}
}, [currentFilesRef, focused, listRef])
}, [currentFilesRef])

return (
<section ref={drop} className={classnames('FilesList no-select sans-serif border-box w-100 flex flex-column', className)}>
@@ -335,7 +419,8 @@ export const FilesList = ({
onRowsRendered={onRowsRendered}
isScrolling={isScrolling}
onScroll={onChildScroll}
scrollTop={scrollTop}/>
scrollTop={scrollTop}
/>
)}
</AutoSizer>
</div>
14 changes: 1 addition & 13 deletions src/files/modals/Modals.js
Original file line number Diff line number Diff line change
@@ -12,7 +12,6 @@ import RemoveModal from './remove-modal/RemoveModal.js'
import AddByPathModal from './add-by-path-modal/AddByPathModal.js'
import BulkImportModal from './bulk-import-modal/bulk-import-modal.tsx'
import PublishModal from './publish-modal/PublishModal.js'
import ShortcutModal from './shortcut-modal/shortcut-modal.js'
import CliTutorMode from '../../components/cli-tutor-mode/CliTutorMode.js'
import { cliCommandList, cliCmdKeys } from '../../bundles/files/consts.js'
import { realMfsPath } from '../../bundles/files/actions.js'
@@ -28,7 +27,6 @@ const BULK_CID_IMPORT = 'bulk_cid_import'
const CLI_TUTOR_MODE = 'cli_tutor_mode'
const PINNING = 'pinning'
const PUBLISH = 'publish'
const SHORTCUTS = 'shortcuts'

export {
NEW_FOLDER,
@@ -40,8 +38,7 @@ export {
BULK_CID_IMPORT,
CLI_TUTOR_MODE,
PINNING,
PUBLISH,
SHORTCUTS
PUBLISH
}

class Modals extends React.Component {
@@ -196,9 +193,6 @@ class Modals extends React.Component {
publish: { file }
})
}
case SHORTCUTS:
this.setState({ readyToShow: true })
break
default:
// do nothing
}
@@ -311,12 +305,6 @@ class Modals extends React.Component {
onLeave={this.leave}
onSubmit={this.publish} />
</Overlay>

<Overlay show={show === SHORTCUTS && readyToShow} onLeave={this.leave}>
<ShortcutModal
className='outline-0'
onLeave={this.leave} />
</Overlay>
</div>
)
}
184 changes: 0 additions & 184 deletions src/files/modals/shortcut-modal/shortcut-modal.js

This file was deleted.

191 changes: 191 additions & 0 deletions src/files/modals/shortcut-modal/shortcut-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import React, { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
// @ts-ignore
import { Modal } from '../../../components/modal/Modal'
import { useShortcuts } from '../../../contexts/ShortcutsContext'

interface KeySymbols {
[key: string]: string | {
[key: string]: string
}
mac: {
[key: string]: string
}
other: {
[key: string]: string
}
}

const keySymbols: KeySymbols = {
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
Enter: '↵',
Space: 'Space',
Escape: 'Esc',
Delete: 'Del',
Backspace: '⌫',
mac: {
Meta: '⌘',
Alt: '⌥',
Shift: 'Shift',
Control: 'Ctrl',
Ctrl: 'Ctrl'
},
other: {
Meta: 'Win',
Alt: 'Alt',
Shift: 'Shift',
Control: 'Ctrl',
Ctrl: 'Ctrl'
}
}

type PlatformType = 'mac' | 'other'

interface KeyboardKeyProps {
children: string
platform: PlatformType
}

const KeyboardKey: React.FC<KeyboardKeyProps> = ({ children, platform }) => {
const getKeySymbol = (key: string): string => {
if (keySymbols[key]) return keySymbols[key] as string
if (platform === 'mac' && keySymbols.mac[key]) return keySymbols.mac[key]
if (platform !== 'mac' && keySymbols.other[key]) return keySymbols.other[key]
return key
}

return (
<kbd className="dib v-mid lh-solid br2 charcoal ba b--gray br2 f7 fw6"
style={{ minWidth: 'fit-content', padding: '6px', height: 'fit-content', textAlign: 'center' }}>
{getKeySymbol(children)}
</kbd>
)
}

interface ShortcutItemProps {
shortcut: string | string[]
description: string
platform: PlatformType,
hidden?: boolean
}

const ShortcutItem: React.FC<ShortcutItemProps> = ({ shortcut, description, platform }) => (
<div className="flex items-center justify-between pa2 bb b--black-10">
<div className="w-60 black f7">{description}</div>
<div className="w-40 tr">
{Array.isArray(shortcut)
? shortcut.map((key, i) => (
<React.Fragment key={i}>
<KeyboardKey platform={platform}>{key}</KeyboardKey>
{i < shortcut.length - 1 && <span className="mr1 gray">+</span>}
</React.Fragment>))
: <KeyboardKey platform={platform}>{shortcut}</KeyboardKey>}
</div>
</div>
)

interface ShortcutData {
shortcut: string | string[]
description: string
hidden?: boolean
}

interface ShortcutSectionProps {
title: string
shortcuts: ShortcutData[]
platform: PlatformType
}

const ShortcutSection: React.FC<ShortcutSectionProps> = ({ title, shortcuts, platform }) => (
<div className="ba b--black-20">
<h3 className="f7 fw6 bb b--black-20 black pa2 ma0">{title}</h3>
<div className="br1">
{shortcuts.filter(shortcut => !shortcut.hidden).map((shortcut, i) => (
<ShortcutItem
key={i}
shortcut={shortcut.shortcut}
description={shortcut.description}
platform={platform}
/>
))}
</div>
</div>
)

interface ShortcutModalProps {
onLeave: () => void
className?: string
}

const ShortcutModal: React.FC<ShortcutModalProps> = ({ onLeave, className = '', ...props }) => {
const [platform, setPlatform] = useState<PlatformType>('other')
const { t } = useTranslation('app')
const shortcuts = useShortcuts()

useEffect(() => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 ||
(navigator.userAgent.includes('Mac') && !navigator.userAgent.includes('Mobile'))
setPlatform(isMac ? 'mac' : 'other')
}, [])

const groupedShortcuts = useMemo(() => {
return shortcuts.reduce((acc, shortcut) => {
const group = shortcut.group || 'Other'
if (!acc[group]) {
acc[group] = []
}
acc[group].push({
shortcut: shortcut.keys,
description: shortcut.label,
hidden: shortcut.hidden
})
return acc
}, {} as Record<string, ShortcutData[]>)
}, [shortcuts])

const groupOrder = ['General', 'Navigation', 'Selection', 'Actions', 'Other']

const sortedGroups = useMemo(() => {
return Object.entries(groupedShortcuts)
.sort(([a], [b]) => {
const indexA = groupOrder.indexOf(a)
const indexB = groupOrder.indexOf(b)
if (indexA === -1) return 1
if (indexB === -1) return -1
return indexA - indexB
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupedShortcuts])

return (
<Modal {...props} className={`${className} bg-near-black white`} onCancel={onLeave}>
<div className="flex items-center justify-between pa2 bb b--black-20">
<h2 className="ma0 f5 fw6 black">{t('shortcutModal.title')}</h2>
</div>

<div className="pa2 overflow-auto" style={{ maxHeight: '70vh' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '.5rem'
}}
>
{sortedGroups.map(([group, shortcuts]) => (
<ShortcutSection
key={group}
title={group}
shortcuts={shortcuts}
platform={platform}
/>
))}
</div>
</div>
</Modal>
)
}

export default ShortcutModal
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react'
import { SVGProps } from 'react'

function SvgGlyphSmallCancel (props) {
function SvgGlyphSmallCancel (props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" {...props}>
<path d="M63.79 63.2l-3.88 3.88-9.71-9.7-9.7 9.7-3.88-3.88 9.7-9.71-9.7-9.7 3.88-3.88 9.7 9.7 9.71-9.7 3.88 3.88-9.7 9.7 9.7 9.71z" />
6 changes: 4 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@ import i18n from './i18n.js'
import { DndProvider } from 'react-dnd'
import DndBackend from './lib/dnd-backend.js'
import { HeliaProvider, ExploreProvider } from 'ipld-explorer-components/providers'

import { ShortcutsProvider } from './contexts/ShortcutsContext.js'
const appVersion = process.env.REACT_APP_VERSION
const gitRevision = process.env.REACT_APP_GIT_REV

@@ -37,7 +37,9 @@ async function render () {
<DndProvider backend={DndBackend}>
<HeliaProvider>
<ExploreProvider>
<App />
<ShortcutsProvider>
<App />
</ShortcutsProvider>
</ExploreProvider>
</HeliaProvider>
</DndProvider>
21 changes: 7 additions & 14 deletions src/peers/AddConnection/AddConnection.js
Original file line number Diff line number Diff line change
@@ -23,19 +23,12 @@ const multiaddrIsValid = (addrString) => {

class AddConnection extends React.Component {
state = {
open: false,
loading: false,
isValid: false,
permanent: true,
maddr: ''
}

toggleModal = () => {
this.setState({
open: !this.state.open
})
}

onPeeringToggle = () => {
this.setState({ permanent: !this.state.permanent })
}
@@ -61,7 +54,7 @@ class AddConnection extends React.Component {

if (errored) return
this.setState({ isValid: false, permanent: true, maddr: '' })
this.toggleModal()
this.props.setIsOpen(false)
}

onKeyPress = (event) => {
@@ -101,17 +94,17 @@ class AddConnection extends React.Component {
}

render () {
const { open, loading } = this.state
const { t } = this.props
const { loading } = this.state
const { t, isOpen, setIsOpen } = this.props

return (
<div>
<Button onClick={this.toggleModal} className='f6 ph3 tc' bg='bg-navy' color='white'>
<Button onClick={() => setIsOpen(true)} className='f6 ph3 tc' bg='bg-navy' color='white'>
<span style={{ color: '#8CDDE6' }}>+</span> {t('addConnection')}
</Button>

<Overlay show={open} onLeave={this.toggleModal}>
<Modal onCancel={this.toggleModal}>
<Overlay show={isOpen} onLeave={() => setIsOpen(false)}>
<Modal onCancel={() => setIsOpen(false)}>
<ModalBody title={t('addConnection')} Icon={Icon}>
{ this.description }

@@ -132,7 +125,7 @@ class AddConnection extends React.Component {
</ModalBody>

<ModalActions>
<Button className='ma2 tc' bg='bg-gray' onClick={this.toggleModal}>{t('actions.cancel')}</Button>
<Button className='ma2 tc' bg='bg-gray' onClick={() => setIsOpen(false)}>{t('actions.cancel')}</Button>
<Button className='ma2 tc' bg='bg-teal' disabled={this.isDisabled} onClick={this.onSubmit}>{t('app:actions.add')}</Button>
</ModalActions>

33 changes: 28 additions & 5 deletions src/peers/PeersPage.js
Original file line number Diff line number Diff line change
@@ -14,16 +14,39 @@ import PeersTable from './PeersTable/PeersTable.js'
import AddConnection from './AddConnection/AddConnection.js'
import CliTutorMode from '../components/cli-tutor-mode/CliTutorMode.js'
import { cliCmdKeys, cliCommandList } from '../bundles/files/consts.js'
import { useShortcuts } from '../contexts/ShortcutsContext.js'

const PeersPage = ({ t, toursEnabled, handleJoyrideCallback }) => (
<div data-id='PeersPage' className='overflow-hidden'>
const PeersPage = ({ t, toursEnabled, handleJoyrideCallback }) => {
const [isOpen, setIsOpen] = React.useState(false)

useShortcuts([{
keys: ['Meta', 'c'],
label: t('addConnection'),
action: () => {
setIsOpen((prev) => !prev)
},
group: t('app:shortcutModal.general')
}, {
keys: ['Shift', 'F'],
label: t('peers:filterPeers'),
action: () => {
const filterInput = document.getElementById('peers-filter')

if (filterInput) {
filterInput.focus()
}
},
group: t('app:shortcutModal.general')
}])

return (<div data-id='PeersPage' className='overflow-hidden'>
<Helmet>
<title>{t('title')} | IPFS</title>
</Helmet>

<div className='flex justify-end items-center mb3'>
<CliTutorMode showIcon={true} command={cliCommandList[cliCmdKeys.ADD_NEW_PEER]()} t={t}/>
<AddConnection />
<AddConnection isOpen={isOpen} setIsOpen={setIsOpen}/>
</div>

<Box className='pt3 ph3 pb4'>
@@ -40,8 +63,8 @@ const PeersPage = ({ t, toursEnabled, handleJoyrideCallback }) => (
scrollToFirstStep
locale={getJoyrideLocales(t)}
showProgress />
</div>
)
</div>)
}

export default connect(
'selectToursEnabled',
1 change: 1 addition & 0 deletions src/peers/PeersTable/PeersTable.js
Original file line number Diff line number Diff line change
@@ -108,6 +108,7 @@ const FilterInput = ({ setFilter, t, filteredCount }) => {
className='input-reset ba b--black-20 pa2 mb2 db w-100'
type='text'
placeholder='Filter peers'
id='peers-filter'
onChange={(e) => setFilter(e.target.value)}
/>
{/* Now to display the total number of peers filtered out on the right side of the inside of the input */}
12 changes: 12 additions & 0 deletions src/types/react-overlays.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
declare module 'react-overlays' {
import * as React from 'react'
export interface ModalProps {
show?: boolean
className?: string
renderBackdrop?: (props: any) => React.ReactNode
onKeyDown?: (e: React.KeyboardEvent) => void
onBackdropClick?: () => void
[key: string]: unknown
}
export class Modal extends React.Component<ModalProps> {}
}
7 changes: 6 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -101,6 +101,11 @@
"src/icons/GlyphPinCloud.js",
"src/icons/GlyphPin.js",
"src/icons/StrokeCube.js",
"src/files/type-from-ext/extToType.js"
"src/files/type-from-ext/extToType.js",
"src/contexts/ShortcutsContext.tsx",
"src/files/modals/shortcut-modal/shortcut-modal.tsx",
"src/icons/GlyphSmallCancel.tsx",
"src/components/overlay/Overlay.tsx",
"src/icons/GlyphSmallCancel.tsx"
]
}