feat-dev(wip): files table view (#2100)

This commit is contained in:
Aman Harwara
2022-12-20 19:01:24 +05:30
committed by GitHub
parent 343c39e873
commit c94035c1d6
23 changed files with 800 additions and 93 deletions

View File

@@ -1,6 +1,6 @@
import { FileItem } from '@standardnotes/snjs'
export enum PopoverFileItemActionType {
export enum FileItemActionType {
AttachFileToNote,
DetachFileToNote,
DeleteFile,
@@ -10,34 +10,32 @@ export enum PopoverFileItemActionType {
PreviewFile,
}
export type PopoverFileItemAction =
export type FileItemAction =
| {
type: Exclude<
PopoverFileItemActionType,
| PopoverFileItemActionType.RenameFile
| PopoverFileItemActionType.ToggleFileProtection
| PopoverFileItemActionType.PreviewFile
FileItemActionType,
FileItemActionType.RenameFile | FileItemActionType.ToggleFileProtection | FileItemActionType.PreviewFile
>
payload: {
file: FileItem
}
}
| {
type: PopoverFileItemActionType.ToggleFileProtection
type: FileItemActionType.ToggleFileProtection
payload: {
file: FileItem
}
callback: (isProtected: boolean) => void
}
| {
type: PopoverFileItemActionType.RenameFile
type: FileItemActionType.RenameFile
payload: {
file: FileItem
name: string
}
}
| {
type: PopoverFileItemActionType.PreviewFile
type: FileItemActionType.PreviewFile
payload: {
file: FileItem
otherFiles?: FileItem[]

View File

@@ -9,7 +9,7 @@ import {
} from '@standardnotes/ui-services'
import { WebApplication } from '@/Application/Application'
import { PANEL_NAME_NOTES } from '@/Constants/Constants'
import { FileItem, PrefKey, WebAppEvent } from '@standardnotes/snjs'
import { FileItem, PrefKey, SystemViewId, WebAppEvent } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { forwardRef, useCallback, useEffect, useMemo } from 'react'
import ContentList from '@/Components/ContentListView/ContentList'
@@ -37,6 +37,9 @@ import { PanelResizedData } from '@/Types/PanelResizedData'
import { useForwardedRef } from '@/Hooks/useForwardedRef'
import { isMobileScreen } from '@/Utils'
import FloatingAddButton from './FloatingAddButton'
import FilesTableView from '../FilesTableView/FilesTableView'
import { FeaturesController } from '@/Controllers/FeaturesController'
import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk'
type Props = {
accountMenuController: AccountMenuController
@@ -49,6 +52,7 @@ type Props = {
selectionController: SelectedItemsController
searchOptionsController: SearchOptionsController
linkingController: LinkingController
featuresController: FeaturesController
className?: string
id: string
children?: React.ReactNode
@@ -68,6 +72,7 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
selectionController,
searchOptionsController,
linkingController,
featuresController,
className,
id,
children,
@@ -280,6 +285,9 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
}
}, [selectedUuids, innerRef, isCurrentNoteTemplate, renderedItems, panes])
const isFilesTableViewEnabled = featureTrunkEnabled(FeatureTrunkName.FilesTableView)
const shouldShowFilesTableView = isFilesTableViewEnabled && selectedTag?.uuid === SystemViewId.Files
return (
<div
id={id}
@@ -300,12 +308,16 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
addButtonLabel={addButtonLabel}
addNewItem={addNewItem}
isFilesSmartView={isFilesSmartView}
isFilesTableViewEnabled={isFilesTableViewEnabled}
optionsSubtitle={optionsSubtitle}
selectedTag={selectedTag}
filesController={filesController}
itemListController={itemListController}
/>
)}
<SearchBar itemListController={itemListController} searchOptionsController={searchOptionsController} />
{!isFilesTableViewEnabled && (
<SearchBar itemListController={itemListController} searchOptionsController={searchOptionsController} />
)}
<NoAccountWarning
accountMenuController={accountMenuController}
noAccountWarningController={noAccountWarningController}
@@ -324,13 +336,18 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
{!dailyMode && completedFullSync && !renderedItems.length ? (
<p className="empty-items-list opacity-50">No items.</p>
) : null}
{!dailyMode && !completedFullSync && !renderedItems.length ? (
<p className="empty-items-list opacity-50">Loading...</p>
) : null}
{!dailyMode && renderedItems.length ? (
<>
shouldShowFilesTableView ? (
<FilesTableView
application={application}
filesController={filesController}
featuresController={featuresController}
linkingController={linkingController}
/>
) : (
<ContentList
items={renderedItems}
selectedUuids={selectedUuids}
@@ -342,7 +359,7 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
notesController={notesController}
selectionController={selectionController}
/>
</>
)
) : null}
<div className="absolute bottom-0 h-safe-bottom w-full" />
{children}

View File

@@ -44,12 +44,8 @@ const AddItemMenuButton = ({
<>
<button
className={classNames(
'hidden md:flex',
'h-8 w-8 hover:brightness-125',
'z-editor-title-bar ml-3 cursor-pointer items-center',
`justify-center rounded-full border border-solid border-transparent ${
isDailyEntry ? 'bg-danger text-danger-contrast' : 'bg-info text-info-contrast'
}`,
'z-editor-title-bar hidden h-8 w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-transparent hover:brightness-125 md:flex',
isDailyEntry ? 'bg-danger text-danger-contrast' : 'bg-info text-info-contrast',
)}
title={addButtonLabel}
aria-label={addButtonLabel}

View File

@@ -11,6 +11,8 @@ import { AnyTag } from '@/Controllers/Navigation/AnyTagType'
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import AddItemMenuButton from './AddItemMenuButton'
import { FilesController } from '@/Controllers/FilesController'
import SearchButton from './SearchButton'
import { ItemListController } from '@/Controllers/ItemList/ItemListController'
type Props = {
application: WebApplication
@@ -19,9 +21,11 @@ type Props = {
addButtonLabel: string
addNewItem: () => void
isFilesSmartView: boolean
isFilesTableViewEnabled: boolean
optionsSubtitle?: string
selectedTag: AnyTag
filesController: FilesController
itemListController: ItemListController
}
const ContentListHeader = ({
@@ -31,9 +35,11 @@ const ContentListHeader = ({
addButtonLabel,
addNewItem,
isFilesSmartView,
isFilesTableViewEnabled,
optionsSubtitle,
selectedTag,
filesController,
itemListController,
}: Props) => {
const displayOptionsContainerRef = useRef<HTMLDivElement>(null)
const displayOptionsButtonRef = useRef<HTMLButtonElement>(null)
@@ -98,6 +104,14 @@ const ContentListHeader = ({
)
}, [addButtonLabel, addNewItem, filesController, isDailyEntry, isFilesSmartView])
const SearchBarButton = useMemo(() => {
if (!isFilesSmartView || !isFilesTableViewEnabled) {
return null
}
return <SearchButton itemListController={itemListController} />
}, [isFilesSmartView, isFilesTableViewEnabled, itemListController])
const FolderName = useMemo(() => {
return (
<div className="flex min-w-0 flex-grow flex-col break-words pt-1 lg:pt-0">
@@ -125,13 +139,14 @@ const ContentListHeader = ({
<div className={'flex w-full justify-between md:flex'}>
<NavigationMenuButton />
{FolderName}
<div className="flex">
<div className="flex items-center gap-3">
{SearchBarButton}
{OptionsMenu}
{AddButton}
</div>
</div>
)
}, [OptionsMenu, AddButton, FolderName])
}, [FolderName, SearchBarButton, OptionsMenu, AddButton])
const TabletLayout = useMemo(() => {
return (

View File

@@ -0,0 +1,53 @@
import RoundIconButton from '@/Components/Button/RoundIconButton'
import ClearInputButton from '@/Components/ClearInputButton/ClearInputButton'
import Icon from '@/Components/Icon/Icon'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import { ElementIds } from '@/Constants/ElementIDs'
import { ItemListController } from '@/Controllers/ItemList/ItemListController'
import { observer } from 'mobx-react-lite'
import { useState } from 'react'
type Props = {
itemListController: ItemListController
}
const SearchButton = ({ itemListController }: Props) => {
const { noteFilterText, setNoteFilterText, clearFilterText } = itemListController
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false)
return (
<>
{isSearchBarVisible && (
<DecoratedInput
autocomplete={false}
id={ElementIds.SearchBar}
className={{
container: 'px-1',
input: 'text-base placeholder:text-passive-0 lg:text-sm',
}}
placeholder={'Search...'}
value={noteFilterText}
ref={(node) => {
if (node && document.activeElement !== node) {
node.focus()
}
}}
onChange={(query) => setNoteFilterText(query)}
left={[<Icon type="search" className="mr-1 h-4.5 w-4.5 flex-shrink-0 text-passive-1" />]}
right={[noteFilterText && <ClearInputButton onClick={clearFilterText} />]}
roundedFull
/>
)}
<RoundIconButton
onClick={() => {
setIsSearchBarVisible(!isSearchBarVisible)
}}
icon={isSearchBarVisible ? 'close' : 'search'}
label="Display options menu"
/>
</>
)
}
export default observer(SearchButton)

View File

@@ -13,6 +13,7 @@ type Props = {
const FileContextMenu: FunctionComponent<Props> = observer(({ filesController, selectionController }) => {
const { showFileContextMenu, setShowFileContextMenu, fileContextMenuLocation } = filesController
const { selectedFiles } = selectionController
return (
<Popover
@@ -25,7 +26,7 @@ const FileContextMenu: FunctionComponent<Props> = observer(({ filesController, s
<Menu a11yLabel="File context menu" isOpen={showFileContextMenu}>
<FileMenuOptions
filesController={filesController}
selectionController={selectionController}
selectedFiles={selectedFiles}
closeMenu={() => setShowFileContextMenu(false)}
shouldShowRenameOption={false}
shouldShowAttachOption={false}

View File

@@ -1,9 +1,8 @@
import { FunctionComponent, useCallback, useMemo } from 'react'
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import { FileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import Icon from '@/Components/Icon/Icon'
import { observer } from 'mobx-react-lite'
import { FilesController } from '@/Controllers/FilesController'
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { useResponsiveAppPane } from '../Panes/ResponsivePaneProvider'
@@ -11,27 +10,27 @@ import { AppPaneId } from '../Panes/AppPaneMetadata'
import MenuItem from '../Menu/MenuItem'
import { FileContextMenuBackupOption } from './FileContextMenuBackupOption'
import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem'
import { FileItem } from '@standardnotes/snjs'
type Props = {
closeMenu: () => void
filesController: FilesController
selectionController: SelectedItemsController
isFileAttachedToNote?: boolean
renameToggleCallback?: (isRenamingFile: boolean) => void
shouldShowRenameOption: boolean
shouldShowAttachOption: boolean
selectedFiles: FileItem[]
}
const FileMenuOptions: FunctionComponent<Props> = ({
closeMenu,
filesController,
selectionController,
isFileAttachedToNote,
renameToggleCallback,
shouldShowRenameOption,
shouldShowAttachOption,
selectedFiles,
}) => {
const { selectedFiles } = selectionController
const { handleFileAction } = filesController
const { toggleAppPane } = useResponsiveAppPane()
@@ -46,7 +45,7 @@ const FileMenuOptions: FunctionComponent<Props> = ({
const onDetach = useCallback(() => {
const file = selectedFiles[0]
void handleFileAction({
type: PopoverFileItemActionType.DetachFileToNote,
type: FileItemActionType.DetachFileToNote,
payload: { file },
})
closeMenu()
@@ -55,7 +54,7 @@ const FileMenuOptions: FunctionComponent<Props> = ({
const onAttach = useCallback(() => {
const file = selectedFiles[0]
void handleFileAction({
type: PopoverFileItemActionType.AttachFileToNote,
type: FileItemActionType.AttachFileToNote,
payload: { file },
})
closeMenu()
@@ -86,7 +85,7 @@ const FileMenuOptions: FunctionComponent<Props> = ({
<MenuSwitchButtonItem
checked={hasProtectedFiles}
onChange={(hasProtectedFiles) => {
void filesController.setProtectionForFiles(hasProtectedFiles, selectionController.selectedFiles)
void filesController.setProtectionForFiles(hasProtectedFiles, selectedFiles)
}}
>
<Icon type="lock" className="mr-2 text-neutral" />
@@ -95,7 +94,7 @@ const FileMenuOptions: FunctionComponent<Props> = ({
<HorizontalSeparator classes="my-1" />
<MenuItem
onClick={() => {
void filesController.downloadFiles(selectionController.selectedFiles)
void filesController.downloadFiles(selectedFiles)
}}
>
<Icon type="download" className="mr-2 text-neutral" />
@@ -114,7 +113,7 @@ const FileMenuOptions: FunctionComponent<Props> = ({
<MenuItem
onClick={() => {
closeMenuAndToggleFilesList()
void filesController.deleteFilesPermanently(selectionController.selectedFiles)
void filesController.deleteFilesPermanently(selectedFiles)
}}
>
<Icon type="trash" className="mr-2 text-danger" />

View File

@@ -25,7 +25,7 @@ const FilesOptionsPanel = ({ filesController, selectionController }: Props) => {
<Menu a11yLabel="File options panel" isOpen={isOpen}>
<FileMenuOptions
filesController={filesController}
selectionController={selectionController}
selectedFiles={selectionController.selectedFiles}
closeMenu={() => {
setIsOpen(false)
}}

View File

@@ -0,0 +1,315 @@
import { WebApplication } from '@/Application/Application'
import { PrefDefaults } from '@/Constants/PrefDefaults'
import { FilesController } from '@/Controllers/FilesController'
import { formatDateForContextMenu } from '@/Utils/DateUtils'
import { getIconForFileType } from '@/Utils/Items/Icons/getIconForFileType'
import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { ContentType, FileItem, SortableItem, PrefKey, ApplicationEvent, naturalSort } from '@standardnotes/snjs'
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { FileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import { getFileIconComponent } from '../FilePreview/getFileIconComponent'
import Popover from '../Popover/Popover'
import Table from '../Table/Table'
import { TableColumn } from '../Table/CommonTypes'
import { useTable } from '../Table/useTable'
import Menu from '../Menu/Menu'
import FileMenuOptions from '../FileContextMenu/FileMenuOptions'
import Icon from '../Icon/Icon'
import { createLinkFromItem } from '@/Utils/Items/Search/createLinkFromItem'
import LinkedItemBubble from '../LinkedItems/LinkedItemBubble'
import LinkedItemsPanel from '../LinkedItems/LinkedItemsPanel'
import { LinkingController } from '@/Controllers/LinkingController'
import { FeaturesController } from '@/Controllers/FeaturesController'
const ContextMenuCell = ({ files, filesController }: { files: FileItem[]; filesController: FilesController }) => {
const [contextMenuVisible, setContextMenuVisible] = useState(false)
const anchorElementRef = useRef<HTMLButtonElement>(null)
return (
<>
<button
className="rounded-full border border-border bg-default p-1"
ref={anchorElementRef}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setContextMenuVisible((visible) => !visible)
}}
>
<Icon type="more" />
</button>
<Popover
open={contextMenuVisible}
anchorElement={anchorElementRef.current}
togglePopover={() => {
setContextMenuVisible(false)
}}
side="bottom"
align="start"
className="py-2"
>
<Menu a11yLabel="File context menu" isOpen={contextMenuVisible}>
<FileMenuOptions
closeMenu={() => {
setContextMenuVisible(false)
}}
filesController={filesController}
shouldShowRenameOption={false}
shouldShowAttachOption={false}
selectedFiles={files}
/>
</Menu>
</Popover>
</>
)
}
const FileLinksCell = ({
file,
filesController,
linkingController,
featuresController,
}: {
file: FileItem
filesController: FilesController
linkingController: LinkingController
featuresController: FeaturesController
}) => {
const [contextMenuVisible, setContextMenuVisible] = useState(false)
const anchorElementRef = useRef<HTMLButtonElement>(null)
return (
<>
<button
className="rounded-full border border-border bg-default p-1"
ref={anchorElementRef}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setContextMenuVisible((visible) => !visible)
}}
>
<Icon type="link" />
</button>
<Popover
open={contextMenuVisible}
anchorElement={anchorElementRef.current}
togglePopover={() => {
setContextMenuVisible(false)
}}
side="bottom"
align="start"
className="py-2"
>
<LinkedItemsPanel
linkingController={linkingController}
filesController={filesController}
featuresController={featuresController}
isOpen={contextMenuVisible}
item={file}
/>
</Popover>
</>
)
}
type Props = {
application: WebApplication
filesController: FilesController
featuresController: FeaturesController
linkingController: LinkingController
}
const FilesTableView = ({ application, filesController, featuresController, linkingController }: Props) => {
const files = application.items
.getDisplayableNotesAndFiles()
.filter((item) => item.content_type === ContentType.File) as FileItem[]
const [sortBy, setSortBy] = useState<keyof SortableItem>(
application.getPreference(PrefKey.SortNotesBy, PrefDefaults[PrefKey.SortNotesBy]),
)
const [sortReversed, setSortReversed] = useState(
application.getPreference(PrefKey.SortNotesReverse, PrefDefaults[PrefKey.SortNotesReverse]),
)
useEffect(() => {
return application.addEventObserver(async (event) => {
if (event === ApplicationEvent.PreferencesChanged) {
setSortBy(application.getPreference(PrefKey.SortNotesBy, PrefDefaults[PrefKey.SortNotesBy]))
setSortReversed(application.getPreference(PrefKey.SortNotesReverse, PrefDefaults[PrefKey.SortNotesReverse]))
}
})
}, [application])
const onSortChange = useCallback(
async (sortBy: keyof SortableItem, sortReversed: boolean) => {
await application.setPreference(PrefKey.SortNotesBy, sortBy)
await application.setPreference(PrefKey.SortNotesReverse, sortReversed)
},
[application],
)
const [contextMenuFile, setContextMenuFile] = useState<FileItem | undefined>(undefined)
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | undefined>(undefined)
const columnDefs: TableColumn<FileItem>[] = useMemo(
() => [
{
name: 'Name',
sortBy: 'title',
cell: (file) => {
return (
<div className="flex max-w-[40vw] items-center gap-3 whitespace-normal">
{getFileIconComponent(getIconForFileType(file.mimeType), 'w-6 h-6 flex-shrink-0')}
<span className="text-sm font-medium">{file.title}</span>
{file.protected && (
<span className="flex items-center" title="File is protected">
<Icon
ariaLabel="File is protected"
type="lock-filled"
className="h-3.5 w-3.5 text-passive-1"
size="custom"
/>
</span>
)}
</div>
)
},
},
{
name: 'Upload date',
sortBy: 'created_at',
cell: (file) => {
return formatDateForContextMenu(file.created_at)
},
},
{
name: 'Size',
cell: (file) => {
return formatSizeToReadableString(file.decryptedSize)
},
},
{
name: 'Attached to',
cell: (file) => {
const links = [
...naturalSort(application.items.referencesForItem(file), 'title').map((item) =>
createLinkFromItem(item, 'linked'),
),
...naturalSort(application.items.itemsReferencingItem(file), 'title').map((item) =>
createLinkFromItem(item, 'linked-by'),
),
...application.items.getSortedTagsForItem(file).map((item) => createLinkFromItem(item, 'linked')),
]
if (!links.length) {
return null
}
return (
<div className="flex max-w-76 flex-wrap gap-2">
<LinkedItemBubble
className="overflow-hidden"
link={links[0]}
key={links[0].id}
unlinkItem={async (itemToUnlink) => {
void application.items.unlinkItems(file, itemToUnlink)
}}
isBidirectional={false}
/>
{links.length > 1 && <span>and {links.length - 1} more...</span>}
</div>
)
},
},
],
[application.items],
)
const getRowId = useCallback((file: FileItem) => file.uuid, [])
const table = useTable({
data: files,
sortBy,
sortReversed,
onSortChange,
getRowId,
columns: columnDefs,
enableRowSelection: true,
enableMultipleRowSelection: true,
onRowDoubleClick(file) {
void filesController.handleFileAction({
type: FileItemActionType.PreviewFile,
payload: {
file,
},
})
},
onRowContextMenu(x, y, file) {
setContextMenuPosition({ x, y })
setContextMenuFile(file)
},
rowActions: (file) => {
const links = [
...naturalSort(application.items.referencesForItem(file), 'title').map((item) =>
createLinkFromItem(item, 'linked'),
),
...naturalSort(application.items.itemsReferencingItem(file), 'title').map((item) =>
createLinkFromItem(item, 'linked-by'),
),
...application.items.getSortedTagsForItem(file).map((item) => createLinkFromItem(item, 'linked')),
]
return (
<div className="flex items-center gap-2">
{links.length > 0 && (
<FileLinksCell
file={file}
filesController={filesController}
featuresController={featuresController}
linkingController={linkingController}
/>
)}
<ContextMenuCell files={[file]} filesController={filesController} />
</div>
)
},
selectionActions: (fileIds) => (
<ContextMenuCell files={files.filter((file) => fileIds.includes(file.uuid))} filesController={filesController} />
),
showSelectionActions: true,
})
return (
<>
<Table table={table} />
{contextMenuPosition && contextMenuFile && (
<Popover
open={true}
anchorPoint={contextMenuPosition}
togglePopover={() => {
setContextMenuPosition(undefined)
setContextMenuFile(undefined)
}}
side="bottom"
align="start"
className="py-2"
>
<Menu a11yLabel="File context menu" isOpen={true}>
<FileMenuOptions
closeMenu={() => {
setContextMenuPosition(undefined)
setContextMenuFile(undefined)
}}
filesController={filesController}
shouldShowRenameOption={false}
shouldShowAttachOption={false}
selectedFiles={[contextMenuFile]}
/>
</Menu>
</Popover>
)}
</>
)
}
export default FilesTableView

View File

@@ -2,6 +2,8 @@ import * as icons from '@standardnotes/icons'
export const IconNameToSvgMapping = {
'account-circle': icons.AccountCircleIcon,
'arrow-up': icons.ArrowUpIcon,
'arrow-down': icons.ArrowDownIcon,
'arrow-left': icons.ArrowLeftIcon,
'arrow-right': icons.ArrowRightIcon,
'arrows-sort-down': icons.ArrowsSortDownIcon,

View File

@@ -2,7 +2,7 @@ import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { FilesController } from '@/Controllers/FilesController'
import { FileItem } from '@standardnotes/snjs'
import { useState } from 'react'
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import { FileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import { FileContextMenuBackupOption } from '../FileContextMenu/FileContextMenuBackupOption'
import Icon from '../Icon/Icon'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
@@ -24,7 +24,7 @@ const LinkedFileMenuOptions = ({ file, closeMenu, handleFileAction, setIsRenamin
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
void handleFileAction({
type: PopoverFileItemActionType.PreviewFile,
type: FileItemActionType.PreviewFile,
payload: {
file,
otherFiles: [],
@@ -41,7 +41,7 @@ const LinkedFileMenuOptions = ({ file, closeMenu, handleFileAction, setIsRenamin
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.ToggleFileProtection,
type: FileItemActionType.ToggleFileProtection,
payload: { file },
callback: (isProtected: boolean) => {
setIsFileProtected(isProtected)
@@ -60,7 +60,7 @@ const LinkedFileMenuOptions = ({ file, closeMenu, handleFileAction, setIsRenamin
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DownloadFile,
type: FileItemActionType.DownloadFile,
payload: { file },
}).catch(console.error)
closeMenu()
@@ -83,7 +83,7 @@ const LinkedFileMenuOptions = ({ file, closeMenu, handleFileAction, setIsRenamin
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DeleteFile,
type: FileItemActionType.DeleteFile,
payload: { file },
}).catch(console.error)
closeMenu()

View File

@@ -11,7 +11,6 @@ import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
import { ItemLink } from '@/Utils/Items/Search/ItemLink'
import { FOCUS_TAGS_INPUT_COMMAND, keyboardStringForShortcut } from '@standardnotes/ui-services'
import { useCommandService } from '../CommandProvider'
import { useApplication } from '../ApplicationProvider'
import { useItemLinks } from '@/Hooks/useItemLinks'
type Props = {
@@ -19,14 +18,11 @@ type Props = {
}
const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
const application = useApplication()
const activeItem = application.itemControllerGroup.activeItemViewController?.item
const { toggleAppPane } = useResponsiveAppPane()
const commandService = useCommandService()
const { unlinkItemFromSelectedItem: unlinkItem, activateItem } = linkingController
const { activeItem, unlinkItemFromSelectedItem: unlinkItem, activateItem } = linkingController
const { notesLinkedToItem, filesLinkedToItem, tagsLinkedToItem, notesLinkingToItem, filesLinkingToItem } =
useItemLinks(activeItem)

View File

@@ -3,7 +3,6 @@ import { FilesController } from '@/Controllers/FilesController'
import { LinkingController } from '@/Controllers/LinkingController'
import { observer } from 'mobx-react-lite'
import { useRef, useCallback } from 'react'
import { useApplication } from '../ApplicationProvider'
import RoundIconButton from '../Button/RoundIconButton'
import Popover from '../Popover/Popover'
import StyledTooltip from '../StyledTooltip/StyledTooltip'
@@ -17,10 +16,7 @@ type Props = {
}
const LinkedItemsButton = ({ linkingController, filesController, onClickPreprocessing, featuresController }: Props) => {
const application = useApplication()
const activeItem = application.itemControllerGroup.activeItemViewController?.item
const { isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController
const { activeItem, isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController
const buttonRef = useRef<HTMLButtonElement>(null)
const toggleMenu = useCallback(async () => {

View File

@@ -13,6 +13,7 @@ import DecoratedInput from '../Input/DecoratedInput'
import LinkedItemSearchResults from './LinkedItemSearchResults'
import { LinkedItemsSectionItem } from './LinkedItemsSectionItem'
import { DecryptedItem } from '@standardnotes/snjs'
import { useItemLinks } from '@/Hooks/useItemLinks'
const LinkedItemsPanel = ({
linkingController,
@@ -27,22 +28,10 @@ const LinkedItemsPanel = ({
isOpen: boolean
item: DecryptedItem
}) => {
const {
getLinkedTagsForItem,
getFilesLinksForItem,
getLinkedNotesForItem,
getNotesLinkingToItem,
linkItems,
unlinkItemFromSelectedItem,
activateItem,
createAndAddNewTag,
isEntitledToNoteLinking,
} = linkingController
const { linkItems, unlinkItems, activateItem, createAndAddNewTag, isEntitledToNoteLinking } = linkingController
const tagsLinkedToItem = getLinkedTagsForItem(item) || []
const { filesLinkedToItem, filesLinkingToItem } = getFilesLinksForItem(item)
const notesLinkedToItem = getLinkedNotesForItem(item) || []
const notesLinkingToItem = getNotesLinkingToItem(item) || []
const { notesLinkedToItem, notesLinkingToItem, filesLinkedToItem, filesLinkingToItem, tagsLinkedToItem } =
useItemLinks(item)
const { entitledToFiles } = featuresController
const application = useApplication()
@@ -128,7 +117,7 @@ const LinkedItemsPanel = ({
key={link.id}
item={link.item}
searchQuery={searchQuery}
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
unlinkItem={() => unlinkItems(item, link.item)}
activateItem={activateItem}
handleFileAction={filesController.handleFileAction}
/>
@@ -148,7 +137,7 @@ const LinkedItemsPanel = ({
key={link.id}
item={link.item}
searchQuery={searchQuery}
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
unlinkItem={() => unlinkItems(item, link.item)}
activateItem={activateItem}
handleFileAction={filesController.handleFileAction}
/>
@@ -172,7 +161,7 @@ const LinkedItemsPanel = ({
key={link.id}
item={link.item}
searchQuery={searchQuery}
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
unlinkItem={() => unlinkItems(item, link.item)}
activateItem={activateItem}
handleFileAction={filesController.handleFileAction}
/>
@@ -191,7 +180,7 @@ const LinkedItemsPanel = ({
key={link.id}
item={link.item}
searchQuery={searchQuery}
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
unlinkItem={() => unlinkItems(item, link.item)}
activateItem={activateItem}
handleFileAction={filesController.handleFileAction}
/>
@@ -208,7 +197,7 @@ const LinkedItemsPanel = ({
key={link.id}
item={link.item}
searchQuery={searchQuery}
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
unlinkItem={() => unlinkItems(item, link.item)}
activateItem={activateItem}
handleFileAction={filesController.handleFileAction}
/>
@@ -227,7 +216,7 @@ const LinkedItemsPanel = ({
key={link.id}
item={link.item}
searchQuery={searchQuery}
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
unlinkItem={() => unlinkItems(item, link.item)}
activateItem={activateItem}
handleFileAction={filesController.handleFileAction}
/>

View File

@@ -9,7 +9,7 @@ import { FileItem } from '@standardnotes/snjs'
import { KeyboardKey } from '@standardnotes/ui-services'
import { useRef, useState } from 'react'
import { useApplication } from '../ApplicationProvider'
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import { FileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import Icon from '../Icon/Icon'
import MenuItem from '../Menu/MenuItem'
import Popover from '../Popover/Popover'
@@ -46,7 +46,7 @@ export const LinkedItemsSectionItem = ({
return
}
await handleFileAction({
type: PopoverFileItemActionType.RenameFile,
type: FileItemActionType.RenameFile,
payload: {
file: item,
name: name,

View File

@@ -303,6 +303,7 @@ const PanesSystemComponent = () => {
selectionController={viewControllerManager.selectionController}
searchOptionsController={viewControllerManager.searchOptionsController}
linkingController={viewControllerManager.linkingController}
featuresController={viewControllerManager.featuresController}
>
{showPanelResizers && listRef && (
<PanelResizer

View File

@@ -18,6 +18,8 @@ type ExperimentalFeatureItem = {
type Props = {
application: {
setValue: WebApplication['setValue']
getValue: WebApplication['getValue']
features: WebApplication['features']
}
}

View File

@@ -0,0 +1,38 @@
import { SortableItem } from '@standardnotes/snjs'
import { MouseEventHandler, ReactNode } from 'react'
export type TableSortBy = keyof SortableItem
export type TableColumn<Data> = {
name: string
sortBy?: TableSortBy
cell: (data: Data) => ReactNode
}
export type TableRow<Data> = {
id: string
cells: ReactNode[]
isSelected: boolean
rowData: Data
rowActions?: ReactNode
}
export type TableHeader = {
name: string
isSorting: boolean | undefined
sortBy?: TableSortBy
sortReversed: boolean | undefined
onSortChange: () => void
}
export type Table<Data> = {
headers: TableHeader[]
rows: TableRow<Data>[]
handleRowClick: (id: string) => MouseEventHandler<HTMLTableRowElement>
handleRowDoubleClick: (id: string) => MouseEventHandler<HTMLTableRowElement>
handleRowContextMenu: (id: string) => MouseEventHandler<HTMLTableRowElement>
canSelectRows: boolean
selectedRows: string[]
selectionActions: ReactNode | undefined
showSelectionActions: boolean
}

View File

@@ -0,0 +1,82 @@
import { classNames } from '@standardnotes/snjs'
import Icon from '../Icon/Icon'
import { Table } from './CommonTypes'
function Table<Data>({ table }: { table: Table<Data> }) {
return (
<div className="block min-h-0 overflow-auto">
{table.showSelectionActions && table.selectedRows.length >= 2 && (
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-info-0 text-sm font-medium">{table.selectedRows.length} selected</span>
{table.selectedRows.length > 0 && table.selectionActions}
</div>
)}
<table className="w-full">
<thead>
<tr>
{table.headers.map((header, index) => {
return (
<th
className={classNames(
'border-b border-border px-3 pt-3 pb-2 text-left text-sm font-medium text-passive-0',
header.sortBy && 'cursor-pointer hover:bg-info-backdrop hover:underline',
)}
onClick={header.onSortChange}
key={index.toString()}
>
<div className="flex items-center gap-1">
{header.name}
{header.isSorting && (
<Icon
type={header.sortReversed ? 'arrow-up' : 'arrow-down'}
size="custom"
className="h-4.5 w-4.5 text-passive-1"
/>
)}
</div>
</th>
)
})}
</tr>
</thead>
<tbody className="divide-y divide-border whitespace-nowrap">
{table.rows.map((row) => {
return (
<tr
key={row.id}
className={classNames(
'group relative',
row.isSelected && 'bg-info-backdrop',
table.canSelectRows && 'cursor-pointer hover:bg-contrast',
)}
onClick={table.handleRowClick(row.id)}
onDoubleClick={table.handleRowDoubleClick(row.id)}
onContextMenu={table.handleRowContextMenu(row.id)}
>
{row.cells.map((cell, index) => {
return (
<td key={index} className="py-3 px-3">
{cell}
</td>
)
})}
{row.rowActions ? (
<div
className={classNames(
'absolute right-3 top-1/2 -translate-y-1/2',
row.isSelected ? '' : 'invisible group-hover:visible',
)}
>
{row.rowActions}
</div>
) : null}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
export default Table

View File

@@ -0,0 +1,204 @@
import { MouseEventHandler, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import { useApplication } from '../ApplicationProvider'
import { Table, TableColumn, TableRow, TableSortBy } from './CommonTypes'
type TableSortOptions =
| {
sortBy: TableSortBy
sortReversed: boolean
onSortChange: (sortBy: TableSortBy, reversed: boolean) => void
}
| {
sortBy?: never
sortReversed?: never
onSortChange?: never
}
type TableSelectionOptions =
| {
enableRowSelection: boolean
enableMultipleRowSelection?: boolean
selectedRowIds?: string[]
onRowSelectionChange?: (rowIds: string[]) => void
selectionActions?: (selected: string[]) => ReactNode
showSelectionActions?: boolean
}
| {
enableRowSelection?: never
enableMultipleRowSelection?: never
selectedRowIds?: never
onRowSelectionChange?: never
selectionActions?: never
showSelectionActions?: never
}
type TableRowOptions<Data> = {
getRowId?: (data: Data) => string
onRowDoubleClick?: (data: Data) => void
onRowContextMenu?: (x: number, y: number, data: Data) => void
rowActions?: (data: Data) => ReactNode
}
export type UseTableOptions<Data> = {
data: Data[]
columns: TableColumn<Data>[]
} & TableRowOptions<Data> &
TableSortOptions &
TableSelectionOptions
export function useTable<Data>({
data,
columns,
sortBy,
sortReversed,
onSortChange,
getRowId,
enableRowSelection,
enableMultipleRowSelection,
selectedRowIds,
onRowSelectionChange,
onRowDoubleClick,
onRowContextMenu,
rowActions,
selectionActions,
showSelectionActions,
}: UseTableOptions<Data>): Table<Data> {
const application = useApplication()
const [selectedRows, setSelectedRows] = useState<string[]>(selectedRowIds || [])
useEffect(() => {
if (selectedRowIds) {
setSelectedRows(selectedRowIds)
}
}, [selectedRowIds])
useEffect(() => {
if (onRowSelectionChange) {
onRowSelectionChange(selectedRows)
}
}, [selectedRows, onRowSelectionChange])
const headers = useMemo(
() =>
columns.map((column) => {
return {
name: column.name,
isSorting: sortBy && sortBy === column.sortBy,
sortBy: column.sortBy,
sortReversed: sortReversed,
onSortChange: () => {
if (!onSortChange || !column.sortBy) {
return
}
onSortChange(column.sortBy, sortBy === column.sortBy ? !sortReversed : false)
},
}
}),
[columns, onSortChange, sortBy, sortReversed],
)
const rows: TableRow<Data>[] = useMemo(
() =>
data.map((rowData, index) => {
const cells = columns.map((column) => {
return column.cell(rowData)
})
const id = getRowId ? getRowId(rowData) : index.toString()
const row: TableRow<Data> = {
id,
isSelected: enableRowSelection ? selectedRows.includes(id) : false,
cells,
rowData,
rowActions: rowActions ? rowActions(rowData) : undefined,
}
return row
}),
[columns, data, enableRowSelection, getRowId, rowActions, selectedRows],
)
const handleRowClick = useCallback(
(id: string) => {
const handler: MouseEventHandler<HTMLTableRowElement> = (event) => {
if (!enableRowSelection) {
return
}
const isCmdOrCtrlPressed = application.keyboardService.isMac ? event.metaKey : event.ctrlKey
if (isCmdOrCtrlPressed && enableMultipleRowSelection) {
setSelectedRows((prev) => (prev.includes(id) ? prev.filter((rowId) => rowId !== id) : [...prev, id]))
} else if (event.shiftKey && enableMultipleRowSelection) {
const lastSelectedIndex = rows.findIndex((row) => row.id === selectedRows[selectedRows.length - 1])
const currentIndex = rows.findIndex((row) => row.id === id)
const start = Math.min(lastSelectedIndex, currentIndex)
const end = Math.max(lastSelectedIndex, currentIndex)
const newSelectedRows = rows.slice(start, end + 1).map((row) => row.id)
setSelectedRows(newSelectedRows)
} else {
setSelectedRows([id])
}
}
return handler
},
[application.keyboardService.isMac, enableMultipleRowSelection, enableRowSelection, rows, selectedRows],
)
const handleRowDoubleClick = useCallback(
(id: string) => {
const handler: MouseEventHandler<HTMLTableRowElement> = () => {
if (!onRowDoubleClick) {
return
}
const rowData = rows.find((row) => row.id === id)?.rowData
if (rowData) {
onRowDoubleClick(rowData)
}
}
return handler
},
[onRowDoubleClick, rows],
)
const handleRowContextMenu = useCallback(
(id: string) => {
const handler: MouseEventHandler<HTMLTableRowElement> = (event) => {
if (!onRowContextMenu) {
return
}
event.preventDefault()
const rowData = rows.find((row) => row.id === id)?.rowData
if (rowData) {
setSelectedRows([id])
onRowContextMenu(event.clientX, event.clientY, rowData)
}
}
return handler
},
[onRowContextMenu, rows],
)
const table: Table<Data> = useMemo(
() => ({
headers,
rows,
handleRowClick,
handleRowDoubleClick,
handleRowContextMenu,
selectedRows,
canSelectRows: enableRowSelection || false,
selectionActions: selectionActions ? selectionActions(selectedRows) : undefined,
showSelectionActions: showSelectionActions || false,
}),
[
enableRowSelection,
handleRowClick,
handleRowContextMenu,
handleRowDoubleClick,
headers,
rows,
selectedRows,
selectionActions,
showSelectionActions,
],
)
return table
}

View File

@@ -4,10 +4,7 @@ import {
OnChunkCallbackNoProgress,
} from '@standardnotes/files'
import { FilePreviewModalController } from './FilePreviewModalController'
import {
PopoverFileItemAction,
PopoverFileItemActionType,
} from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
import { FileItemAction, FileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants/Constants'
import { confirmDialog } from '@standardnotes/ui-services'
import { Strings, StringUtils } from '@/Constants/Strings'
@@ -34,8 +31,8 @@ import { AbstractViewController } from './Abstract/AbstractViewController'
import { NotesController } from './NotesController/NotesController'
import { downloadOrShareBlobBasedOnPlatform } from '@/Utils/DownloadOrShareBasedOnPlatform'
const UnprotectedFileActions = [PopoverFileItemActionType.ToggleFileProtection]
const NonMutatingFileActions = [PopoverFileItemActionType.DownloadFile, PopoverFileItemActionType.PreviewFile]
const UnprotectedFileActions = [FileItemActionType.ToggleFileProtection]
const NonMutatingFileActions = [FileItemActionType.DownloadFile, FileItemActionType.PreviewFile]
type FileContextMenuLocation = { x: number; y: number }
@@ -195,7 +192,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
}
handleFileAction = async (
action: PopoverFileItemAction,
action: FileItemAction,
): Promise<{
didHandleAction: boolean
}> => {
@@ -215,27 +212,27 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
}
switch (action.type) {
case PopoverFileItemActionType.AttachFileToNote:
case FileItemActionType.AttachFileToNote:
await this.attachFileToSelectedNote(file)
break
case PopoverFileItemActionType.DetachFileToNote:
case FileItemActionType.DetachFileToNote:
await this.detachFileFromNote(file)
break
case PopoverFileItemActionType.DeleteFile:
case FileItemActionType.DeleteFile:
await this.deleteFile(file)
break
case PopoverFileItemActionType.DownloadFile:
case FileItemActionType.DownloadFile:
await this.downloadFile(file)
break
case PopoverFileItemActionType.ToggleFileProtection: {
case FileItemActionType.ToggleFileProtection: {
const isProtected = await this.toggleFileProtection(file)
action.callback(isProtected)
break
}
case PopoverFileItemActionType.RenameFile:
case FileItemActionType.RenameFile:
await this.renameFile(file, action.payload.name)
break
case PopoverFileItemActionType.PreviewFile:
case FileItemActionType.PreviewFile:
this.filePreviewModalController.activate(file, action.payload.otherFiles)
break
}
@@ -426,7 +423,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
label: 'Open',
handler: (toastId) => {
void this.handleFileAction({
type: PopoverFileItemActionType.PreviewFile,
type: FileItemActionType.PreviewFile,
payload: { file: uploadedFile },
})
dismissToast(toastId)

View File

@@ -1,5 +1,5 @@
import { WebApplication } from '@/Application/Application'
import { PopoverFileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
import { FileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
import { NoteViewController } from '@/Components/NoteView/Controller/NoteViewController'
import { AppPaneId } from '@/Components/Panes/AppPaneMetadata'
import { PrefDefaults } from '@/Constants/PrefDefaults'
@@ -153,7 +153,7 @@ export class LinkingController extends AbstractViewController {
}
} else if (item instanceof FileItem) {
await this.filesController.handleFileAction({
type: PopoverFileItemActionType.PreviewFile,
type: FileItemActionType.PreviewFile,
payload: {
file: item,
otherFiles: [],
@@ -164,6 +164,12 @@ export class LinkingController extends AbstractViewController {
return undefined
}
unlinkItems = async (item: LinkableItem, itemToUnlink: LinkableItem) => {
await this.application.items.unlinkItems(item, itemToUnlink)
void this.application.sync.sync()
}
unlinkItemFromSelectedItem = async (itemToUnlink: LinkableItem) => {
const selectedItem = this.selectionController.firstSelectedItem
@@ -171,9 +177,7 @@ export class LinkingController extends AbstractViewController {
return
}
await this.application.items.unlinkItems(selectedItem, itemToUnlink)
void this.application.sync.sync()
void this.unlinkItems(selectedItem, itemToUnlink)
}
ensureActiveItemIsInserted = async () => {

View File

@@ -2,10 +2,12 @@ import { isDev } from '@/Utils'
export enum FeatureTrunkName {
Super,
FilesTableView,
}
const FeatureTrunkStatus: Record<FeatureTrunkName, boolean> = {
[FeatureTrunkName.Super]: isDev && true,
[FeatureTrunkName.FilesTableView]: isDev && true,
}
export function featureTrunkEnabled(trunk: FeatureTrunkName): boolean {