diff --git a/resources/style/content.scss b/resources/style/content.scss index e32460014..7041146f7 100644 --- a/resources/style/content.scss +++ b/resources/style/content.scss @@ -154,8 +154,8 @@ } .image-loading { - width: var(--thumbnail-size, 100px); - height: var(--thumbnail-size, 100px); + width: 2rem; + aspect-ratio: 1 / 1; margin: auto; border-radius: 50%; border: 0.25rem solid var(--background-color); diff --git a/src/frontend/Preview.tsx b/src/frontend/Preview.tsx index 54b71f78a..41ef643f8 100644 --- a/src/frontend/Preview.tsx +++ b/src/frontend/Preview.tsx @@ -6,7 +6,7 @@ import { useStore } from './contexts/StoreContext'; import ErrorBoundary from './containers/ErrorBoundary'; import ContentView from './containers/ContentView'; import { IconSet, Toggle } from 'widgets'; -import { Toolbar, ToolbarButton } from 'widgets/menus'; +import { ContextMenuLayer, Toolbar, ToolbarButton } from 'widgets/menus'; import { useWorkerListener } from './image/ThumbnailGeneration'; import SplashScreen from './containers/SplashScreen'; @@ -77,7 +77,9 @@ const PreviewApp = observer(() => { /> - + + + ); diff --git a/src/frontend/components/ContextMenu.tsx b/src/frontend/components/ContextMenu.tsx deleted file mode 100644 index 49fe2e85d..000000000 --- a/src/frontend/components/ContextMenu.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import { ContextMenu as BaseContextMenu } from 'widgets/menus'; -import { ContextMenuProps } from 'widgets/menus/ContextMenu'; -import { Portal } from '../hooks/usePortal'; - -// TODO: Merge with useContextMenu hook. -const ContextMenu = ({ isOpen, x, y, close, children }: ContextMenuProps) => { - return ( - - - {children} - - - ); -}; - -export default ContextMenu; diff --git a/src/frontend/components/FileTag.tsx b/src/frontend/components/FileTag.tsx index 321d77929..cda4a048c 100644 --- a/src/frontend/components/FileTag.tsx +++ b/src/frontend/components/FileTag.tsx @@ -5,11 +5,9 @@ import { IconSet } from 'widgets/Icons'; import { Row } from 'widgets'; import { useStore } from '../contexts/StoreContext'; import { TagSelector } from './TagSelector'; -import useContextMenu from '../hooks/useContextMenu'; import { FileTagMenuItems } from '../containers/ContentView/menu-items'; import { ClientTag } from 'src/entities/Tag'; -import { Menu } from 'widgets/menus'; -import ContextMenu from './ContextMenu'; +import { Menu, useContextMenu } from 'widgets/menus'; interface IFileTagProp { file: ClientFile; @@ -18,8 +16,6 @@ interface IFileTagProp { const FileTags = observer(({ file }: IFileTagProp) => { const { tagStore } = useStore(); - const [contextState, { show, hide }] = useContextMenu(); - const renderCreateOption = useCallback( (tagName: string, resetTextBox: () => void) => ( { [file, tagStore], ); + const show = useContextMenu(); const handleTagContextMenu = useCallback( (event: React.MouseEvent, tag: ClientTag) => { event.stopPropagation(); - show(event.clientX, event.clientY, [ - + show( + event.clientX, + event.clientY, + - , - ]); + , + ); }, [file, show], ); return ( - <> - - - {/* TODO: probably not the right place for the ContextMenu component. - Why not a single one at the root element that can be interacted with through a Context? */} - - {contextState.menu} - - + ); }); diff --git a/src/frontend/components/PopupWindow.tsx b/src/frontend/components/PopupWindow.tsx index 708a6c9a9..5382d0ea5 100644 --- a/src/frontend/components/PopupWindow.tsx +++ b/src/frontend/components/PopupWindow.tsx @@ -55,7 +55,8 @@ const PopupWindow: React.FC = (props) => { if (win) { return ReactDOM.createPortal( <> - {props.children} + {props.children} + , containerEl, ); diff --git a/src/frontend/components/TagSelector.tsx b/src/frontend/components/TagSelector.tsx index 1835153fb..394c76958 100644 --- a/src/frontend/components/TagSelector.tsx +++ b/src/frontend/components/TagSelector.tsx @@ -48,10 +48,14 @@ const TagSelector = (props: TagSelectorProps) => { setQuery(e.target.value); }).current; - const clearSelection = useCallback(() => { - setQuery(''); - onClear(); - }, [onClear]); + const clearSelection = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + setQuery(''); + onClear(); + }, + [onClear], + ); const isInputEmpty = query.length === 0; diff --git a/src/frontend/containers/AppToolbar/Searchbar.tsx b/src/frontend/containers/AppToolbar/Searchbar.tsx index a87bcc0e0..d2e7b734f 100644 --- a/src/frontend/containers/AppToolbar/Searchbar.tsx +++ b/src/frontend/containers/AppToolbar/Searchbar.tsx @@ -103,7 +103,8 @@ const QuickSearchList = observer(() => { const SearchMatchButton = observer(({ disabled }: { disabled: boolean }) => { const { fileStore, uiStore } = useStore(); - const handleClick = useRef(() => { + const handleClick = useRef((e: React.MouseEvent) => { + e.stopPropagation(); uiStore.toggleSearchMatchAny(); fileStore.refetch(); }).current; @@ -148,6 +149,7 @@ const CriteriaList = observer(() => { fileStore.refetch(); e.stopPropagation(); e.preventDefault(); + // TODO: search input element keeps focus after click??? }} className="btn-icon-large" /> diff --git a/src/frontend/containers/ContentView/Commands.tsx b/src/frontend/containers/ContentView/Commands.tsx index 35e514682..d4dde3cb5 100644 --- a/src/frontend/containers/ContentView/Commands.tsx +++ b/src/frontend/containers/ContentView/Commands.tsx @@ -6,7 +6,9 @@ import { ClientTag } from 'src/entities/Tag'; import { useStore } from 'src/frontend/contexts/StoreContext'; import { DnDAttribute, DnDTagType, useTagDnD } from 'src/frontend/contexts/TagDnDContext'; import { RendererMessenger } from 'src/Messaging'; -import { MenuDivider } from 'widgets/menus'; +import { IconSet } from 'widgets/Icons'; +import { Menu, MenuDivider, MenuSubItem, useContextMenu } from 'widgets/menus'; +import { LayoutMenuItems, SortMenuItems } from '../AppToolbar/Menus'; import { ExternalAppMenuItems, FileTagMenuItems, @@ -142,12 +144,36 @@ export class CommandDispatcher { export function useCommandHandler( select: (file: ClientFile, selectAdditive: boolean, selectRange: boolean) => void, - showContextMenu: (x: number, y: number, menu: [JSX.Element, JSX.Element]) => void, ) { const dndData = useTagDnD(); const { uiStore } = useStore(); + const show = useContextMenu(); useEffect(() => { + const showContextMenu = ( + x: number, + y: number, + fileMenu: JSX.Element, + externalMenu: JSX.Element, + ) => { + show( + x, + y, + + {fileMenu} + + + + + + + + + {externalMenu} + , + ); + }; + const handleSelect = action((event: Event) => { event.stopPropagation(); const { file, selectAdditive, selectRange } = (event as CommandHandlerEvent) @@ -167,10 +193,12 @@ export function useCommandHandler( const handleContextMenu = action((event: Event) => { event.stopPropagation(); const { file, x, y } = (event as CommandHandlerEvent).detail; - showContextMenu(x, y, [ + showContextMenu( + x, + y, file.isBroken ? : , - , - ]); + , + ); if (!uiStore.fileSelection.has(file)) { // replace selection with context menu, like Windows file explorer select(file, false, false); @@ -180,14 +208,16 @@ export function useCommandHandler( const handleTagContextMenu = action((event: Event) => { event.stopPropagation(); const { file, x, y, tag } = (event as CommandHandlerEvent).detail; - showContextMenu(x, y, [ + showContextMenu( + x, + y, <> {file.isBroken ? : } , - , - ]); + , + ); if (!uiStore.fileSelection.has(file)) { // replace selection with context menu, like Windows file explorer select(file, false, false); @@ -197,10 +227,15 @@ export function useCommandHandler( const handleSlideContextMenu = action((event: Event) => { event.stopPropagation(); const { file, x, y } = (event as CommandHandlerEvent).detail; - showContextMenu(x, y, [ - file.isBroken ? : , - , - ]); + show( + x, + y, + + {file.isBroken ? : } + + + , + ); if (!uiStore.fileSelection.has(file)) { // replace selection with context menu, like Windows file explorer select(file, false, false); @@ -275,7 +310,7 @@ export function useCommandHandler( el.removeEventListener(Selector.FileDragLeave, handleDragLeave, true); el.removeEventListener(Selector.FileDrop, handleDrop, true); }; - }, [uiStore, dndData, select, showContextMenu]); + }, [uiStore, dndData, select, show]); } /** diff --git a/src/frontend/containers/ContentView/GalleryItem.tsx b/src/frontend/containers/ContentView/GalleryItem.tsx index bf74f7501..5877acb9f 100644 --- a/src/frontend/containers/ContentView/GalleryItem.tsx +++ b/src/frontend/containers/ContentView/GalleryItem.tsx @@ -193,16 +193,12 @@ const TagWithHint = observer( tag: ClientTag; onContextMenu: (e: MousePointerEvent, tag: ClientTag) => void; }) => { - const handleContextMenu = useCallback( - (e: React.MouseEvent) => onContextMenu(e, tag), - [onContextMenu, tag], - ); return ( onContextMenu(e, tag)} /> ); }, diff --git a/src/frontend/containers/ContentView/LayoutSwitcher.tsx b/src/frontend/containers/ContentView/LayoutSwitcher.tsx index d06841760..b4967c32b 100644 --- a/src/frontend/containers/ContentView/LayoutSwitcher.tsx +++ b/src/frontend/containers/ContentView/LayoutSwitcher.tsx @@ -14,10 +14,9 @@ import { ContentRect } from './utils'; interface LayoutProps { contentRect: ContentRect; - showContextMenu: (x: number, y: number, menu: [JSX.Element, JSX.Element]) => void; } -const Layout = ({ contentRect, showContextMenu }: LayoutProps) => { +const Layout = ({ contentRect }: LayoutProps) => { const { fileStore, uiStore } = useStore(); // Todo: Select by dragging a rectangle shape @@ -121,7 +120,7 @@ const Layout = ({ contentRect, showContextMenu }: LayoutProps) => { return () => window.clearTimeout(handle); }, [isSlideMode]); - useCommandHandler(handleFileSelect, showContextMenu); + useCommandHandler(handleFileSelect); if (contentRect.width < 10) { return null; diff --git a/src/frontend/containers/ContentView/Placeholder.tsx b/src/frontend/containers/ContentView/Placeholder.tsx index 103f06247..c5681e5e3 100644 --- a/src/frontend/containers/ContentView/Placeholder.tsx +++ b/src/frontend/containers/ContentView/Placeholder.tsx @@ -1,12 +1,16 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { observer } from 'mobx-react-lite'; import LOGO_FC from 'resources/logo/svg/full-color/allusion-logomark-fc.svg'; +import { IS_PREVIEW_WINDOW } from 'common/window'; import { useStore } from '../../contexts/StoreContext'; const Placeholder = observer(() => { const { fileStore, tagStore } = useStore(); + if (IS_PREVIEW_WINDOW) { + return ; + } if (fileStore.showsAllContent && tagStore.isEmpty) { // No tags exist, and no images added: Assuming it's a new user -> Show a welcome screen return ; @@ -26,6 +30,47 @@ const Placeholder = observer(() => { export default Placeholder; import { IconSet, Button, ButtonGroup, SVG } from 'widgets'; +import { RendererMessenger } from 'src/Messaging'; +import useMountState from 'src/frontend/hooks/useMountState'; + +const PreviewWindowPlaceholder = observer(() => { + const { fileStore } = useStore(); + const [isLoading, setIsLoading] = useState(true); + const [, isMounted] = useMountState(); + useEffect(() => { + setIsLoading(true); + setTimeout(() => { + if (isMounted.current) { + setIsLoading(false); + } + }, 1000); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fileStore.fileListLastModified]); + + if (isLoading) { + return ( + }> + {IconSet.LOADING} + + ); + } + + // There should always be images to preview. + // If the placeholder is shown, something went wrong (probably the DB of the preview window is out of sync with the main window) + return ( + }> +

Something went wrong while previewing the selected images

+ +
+ +
); diff --git a/src/frontend/containers/Main.tsx b/src/frontend/containers/Main.tsx index 4ac3c1613..c528a194a 100644 --- a/src/frontend/containers/Main.tsx +++ b/src/frontend/containers/Main.tsx @@ -9,6 +9,7 @@ import AppToolbar from './AppToolbar'; import ContentView from './ContentView'; import Outliner from './Outliner'; import { useAction } from '../hooks/mobx'; +import { ContextMenuLayer } from 'widgets/menus'; const Main = () => { const { uiStore } = useStore(); @@ -61,23 +62,25 @@ const Main = () => { }); return ( - - } - secondary={ -
- - -
- } - axis="vertical" - align="left" - splitPoint={uiStore.outlinerWidth} - isExpanded={uiStore.isOutlinerOpen} - onMove={uiStore.moveOutlinerSplitter} - /> -
+ + + } + secondary={ +
+ + +
+ } + axis="vertical" + align="left" + splitPoint={uiStore.outlinerWidth} + isExpanded={uiStore.isOutlinerOpen} + onMove={uiStore.moveOutlinerSplitter} + /> +
+
); }; diff --git a/src/frontend/containers/Outliner/LocationsPanel/dnd.ts b/src/frontend/containers/Outliner/LocationsPanel/dnd.ts index 138ef8a1f..3ab1b9fac 100644 --- a/src/frontend/containers/Outliner/LocationsPanel/dnd.ts +++ b/src/frontend/containers/Outliner/LocationsPanel/dnd.ts @@ -44,16 +44,17 @@ export function handleDragLeave(event: React.DragEvent) { export async function storeDroppedImage(e: React.DragEvent, directory: string) { const dropData = await getDropData(e); + for (const dataItem of dropData) { let fileData: IStoreFileMessage | undefined; // Store file -> detected by watching the directory -> automatically imported if (dataItem instanceof File) { - const file = await fse.readFile(dataItem.path); + const buffer = dataItem.path ? await fse.readFile(dataItem.path) : dataItem; fileData = { directory, filenameWithExt: path.basename(dataItem.path), - imgBase64: file.toString('base64'), + imgBase64: buffer.toString('base64'), }; } else if (typeof dataItem === 'string') { // It's probably a URL, so we can download it to get the image data diff --git a/src/frontend/containers/Outliner/LocationsPanel/index.tsx b/src/frontend/containers/Outliner/LocationsPanel/index.tsx index 54fbfbe16..af2ed0e4b 100644 --- a/src/frontend/containers/Outliner/LocationsPanel/index.tsx +++ b/src/frontend/containers/Outliner/LocationsPanel/index.tsx @@ -12,13 +12,11 @@ import DropContext from 'src/frontend/contexts/DropContext'; import { useStore } from 'src/frontend/contexts/StoreContext'; import { DnDLocationType, useLocationDnD } from 'src/frontend/contexts/TagDnDContext'; import { useAutorun } from 'src/frontend/hooks/mobx'; -import useContextMenu from 'src/frontend/hooks/useContextMenu'; import LocationStore from 'src/frontend/stores/LocationStore'; import { triggerContextMenuEvent, emptyFunction } from '../utils'; import { RendererMessenger } from 'src/Messaging'; import { IconSet, Tree } from 'widgets'; -import { Menu, MenuDivider, MenuItem, Toolbar, ToolbarButton } from 'widgets/menus'; -import ContextMenu from 'src/frontend/components/ContextMenu'; +import { Menu, MenuDivider, MenuItem, Toolbar, ToolbarButton, useContextMenu } from 'widgets/menus'; import MultiSplitPane, { MultiSplitPaneProps } from 'widgets/MultiSplit/MultiSplitPane'; import { Callout } from 'widgets/notifications'; import { createBranchOnKeyDown, ITreeItem } from 'widgets/Tree'; @@ -80,7 +78,6 @@ const enum Tooltip { } interface ITreeData { - showContextMenu: (x: number, y: number, menu: JSX.Element) => void; expansion: IExpansionState; setExpansion: React.Dispatch; delete: (location: ClientLocation) => void; @@ -200,38 +197,41 @@ const LocationTreeContextMenu = observer(({ location, onDelete, onExclude }: ICo if (location.isBroken) { return ( - <> + uiStore.openLocationRecovery(location.id)} icon={IconSet.WARNING_BROKEN_LINK} /> - + ); } return ( - <> + - + ); }); const SubLocation = observer((props: { nodeData: ClientSubLocation; treeData: ITreeData }) => { const { nodeData, treeData } = props; const { uiStore } = useStore(); - const { showContextMenu, expansion, setExpansion } = treeData; + const { expansion, setExpansion } = treeData; + const show = useContextMenu(); const handleContextMenu = useCallback( (e: React.MouseEvent) => - showContextMenu( + show( e.clientX, e.clientY, - , + + + , ), - [nodeData, showContextMenu, treeData.exclude], + [nodeData, show, treeData.exclude], ); const existingSearchCrit = uiStore.searchCriteriaList.find( @@ -288,10 +288,11 @@ const DnDHelper = createDragReorderHelper('locations-dnd-preview', DnDLocationTy const Location = observer( ({ nodeData, treeData }: { nodeData: ClientLocation; treeData: ITreeData }) => { const { uiStore, locationStore } = useStore(); - const { showContextMenu, expansion, delete: onDelete } = treeData; + const { expansion, delete: onDelete } = treeData; + const show = useContextMenu(); const handleContextMenu = useCallback( (event: React.MouseEvent) => { - showContextMenu( + show( event.clientX, event.clientY, , ); }, - [showContextMenu, nodeData, onDelete, treeData.exclude], + [show, nodeData, onDelete, treeData.exclude], ); // TODO: idem @@ -432,12 +433,11 @@ const LocationLabel = ({ nodeData, treeData }: { nodeData: any; treeData: any }) ); interface ILocationTreeProps { - showContextMenu: (x: number, y: number, menu: JSX.Element) => void; onDelete: (loc: ClientLocation) => void; onExclude: (loc: ClientSubLocation) => void; } -const LocationsTree = ({ onDelete, onExclude, showContextMenu }: ILocationTreeProps) => { +const LocationsTree = ({ onDelete, onExclude }: ILocationTreeProps) => { const { locationStore, uiStore } = useStore(); const [expansion, setExpansion] = useState({}); const treeData: ITreeData = useMemo( @@ -446,9 +446,8 @@ const LocationsTree = ({ onDelete, onExclude, showContextMenu }: ILocationTreePr setExpansion, delete: onDelete, exclude: onExclude, - showContextMenu, }), - [expansion, onDelete, onExclude, showContextMenu], + [expansion, onDelete, onExclude], ); const [branches, setBranches] = useState([]); @@ -503,7 +502,6 @@ const LocationsTree = ({ onDelete, onExclude, showContextMenu }: ILocationTreePr const LocationsPanel = observer((props: Partial) => { const { locationStore } = useStore(); - const [contextState, { show, hide }] = useContextMenu(); const [creatableLocation, setCreatableLocation] = useState(); const [deletableLocation, setDeletableLocation] = useState(); @@ -537,8 +535,11 @@ const LocationsPanel = observer((props: Partial) => { return; } + const addSeparator = (path: string) => (path.endsWith(SysPath.sep) ? path : path + SysPath.sep); + // Check if the new location is a sub-directory of an existing location - const parentDir = locationStore.exists((dir) => path.includes(dir.path)); + // add separator to prevent /foo/bar2 from being detected as parent directory of /foo/bar + const parentDir = locationStore.exists((dir) => path.includes(addSeparator(dir.path))); if (parentDir) { AppToaster.show({ message: 'You cannot add a location that is a sub-folder of an existing location.', @@ -551,7 +552,7 @@ const LocationsPanel = observer((props: Partial) => { // Need to add a separator at the end, otherwise the new path /foo is detected as a parent of existing location /football. // - /foo/ is not a parent directory of /football // - /foo/ is a parent directory of /foo/bar - const pathWithSeparator = path.endsWith(SysPath.sep) ? path : path + SysPath.sep; + const pathWithSeparator = addSeparator(path); const childDir = locationStore.exists((dir) => dir.path.includes(pathWithSeparator)); if (childDir) { AppToaster.show({ @@ -598,11 +599,7 @@ const LocationsPanel = observer((props: Partial) => { } {...props} > - + {isEmpty && Click + to choose a location.} @@ -625,9 +622,6 @@ const LocationsPanel = observer((props: Partial) => { onClose={() => setExcludableSubLocation(undefined)} /> )} - - {contextState.menu} - ); }); diff --git a/src/frontend/containers/Outliner/LocationsPanel/useFileDnD.ts b/src/frontend/containers/Outliner/LocationsPanel/useFileDnD.ts index 6bd6933b5..dbf56dcfe 100644 --- a/src/frontend/containers/Outliner/LocationsPanel/useFileDnD.ts +++ b/src/frontend/containers/Outliner/LocationsPanel/useFileDnD.ts @@ -52,11 +52,11 @@ export const useFileDropHandling = ( console.error(e); AppToaster.show({ message: 'Something went wrong, could not import image :(', - timeout: 100, + timeout: 4000, }); } } else { - AppToaster.show({ message: 'File type not supported :(', timeout: 100 }); + AppToaster.show({ message: 'File type not supported :(', timeout: 4000 }); } }, [fullPath], diff --git a/src/frontend/containers/Outliner/SavedSearchesPanel/index.tsx b/src/frontend/containers/Outliner/SavedSearchesPanel/index.tsx index 0d0d9cf0f..a038250b6 100644 --- a/src/frontend/containers/Outliner/SavedSearchesPanel/index.tsx +++ b/src/frontend/containers/Outliner/SavedSearchesPanel/index.tsx @@ -12,10 +12,8 @@ import { useSearchDnD, } from 'src/frontend/contexts/TagDnDContext'; import { useAutorun } from 'src/frontend/hooks/mobx'; -import useContextMenu from 'src/frontend/hooks/useContextMenu'; import { IconSet } from 'widgets/Icons'; -import { Menu, MenuItem } from 'widgets/menus'; -import ContextMenu from 'src/frontend/components/ContextMenu'; +import { Menu, MenuItem, useContextMenu } from 'widgets/menus'; import MultiSplitPane, { MultiSplitPaneProps } from 'widgets/MultiSplit/MultiSplitPane'; import { Callout } from 'widgets/notifications'; import { Toolbar, ToolbarButton } from 'widgets/Toolbar'; @@ -31,7 +29,6 @@ const enum Tooltip { } interface ITreeData { - showContextMenu: (x: number, y: number, menu: JSX.Element) => void; expansion: IExpansionState; setExpansion: React.Dispatch; delete: (location: ClientFileSearchItem) => void; @@ -121,7 +118,7 @@ interface IContextMenuProps { const SearchItemContextMenu = observer( ({ searchItem, onDelete, onDuplicate, onReplace, onEdit }: IContextMenuProps) => { return ( - <> + onEdit(searchItem)} icon={IconSet.EDIT} /> onDuplicate(searchItem)} icon={IconSet.PLUS} /> onDelete(searchItem)} icon={IconSet.DELETE} /> - + ); }, ); @@ -141,16 +138,11 @@ const SearchItem = observer( ({ nodeData, treeData }: { nodeData: ClientFileSearchItem; treeData: ITreeData }) => { const rootStore = useStore(); const { uiStore, searchStore } = rootStore; - const { - showContextMenu, - edit: onEdit, - duplicate: onDuplicate, - delete: onDelete, - replace: onReplace, - } = treeData; + const { edit: onEdit, duplicate: onDuplicate, delete: onDelete, replace: onReplace } = treeData; + const show = useContextMenu(); const handleContextMenu = useCallback( (event: React.MouseEvent) => { - showContextMenu( + show( event.clientX, event.clientY, , ); }, - [showContextMenu, nodeData, onEdit, onDelete, onDuplicate, onReplace], + [show, nodeData, onEdit, onDelete, onDuplicate, onReplace], ); const handleClick = useCallback( @@ -263,21 +255,6 @@ const SearchItemCriteria = observer( const { uiStore } = rootStore; // TODO: context menu for individual criteria of search items? - // const { showContextMenu, expansion, delete: onDelete } = treeData; - // const handleContextMenu = useCallback( - // (event: React.MouseEvent) => { - // showContextMenu( - // event.clientX, - // event.clientY, - // , - // ); - // }, - // [showContextMenu, nodeData, onDelete, treeData.exclude], - // ); const handleClick = useCallback( (e: React.MouseEvent) => { @@ -316,20 +293,13 @@ const SearchItemCriteria = observer( ); interface ISearchTreeProps { - showContextMenu: (x: number, y: number, menu: JSX.Element) => void; onEdit: (search: ClientFileSearchItem) => void; onDelete: (search: ClientFileSearchItem) => void; onDuplicate: (search: ClientFileSearchItem) => void; onReplace: (search: ClientFileSearchItem) => void; } -const SavedSearchesList = ({ - onDelete, - onEdit, - onDuplicate, - onReplace, - showContextMenu, -}: ISearchTreeProps) => { +const SavedSearchesList = ({ onDelete, onEdit, onDuplicate, onReplace }: ISearchTreeProps) => { const rootStore = useStore(); const { searchStore, uiStore } = rootStore; const [expansion, setExpansion] = useState({}); @@ -341,9 +311,8 @@ const SavedSearchesList = ({ edit: onEdit, duplicate: onDuplicate, replace: onReplace, - showContextMenu, }), - [expansion, onDelete, onDuplicate, onEdit, onReplace, showContextMenu], + [expansion, onDelete, onDuplicate, onEdit, onReplace], ); const [branches, setBranches] = useState([]); @@ -395,7 +364,6 @@ const SavedSearchesList = ({ const SavedSearchesPanel = observer((props: Partial) => { const rootStore = useStore(); const { searchStore, uiStore } = rootStore; - const [contextState, { show, hide }] = useContextMenu(); const isEmpty = searchStore.searchList.length === 0; @@ -436,7 +404,6 @@ const SavedSearchesPanel = observer((props: Partial) => { {...props} > ) => { onClose={() => setDeletableSearch(undefined)} /> )} - - {contextState.menu} - ); diff --git a/src/frontend/containers/Outliner/TagsPanel/TagsTree.tsx b/src/frontend/containers/Outliner/TagsPanel/TagsTree.tsx index aa73abd06..393215646 100644 --- a/src/frontend/containers/Outliner/TagsPanel/TagsTree.tsx +++ b/src/frontend/containers/Outliner/TagsPanel/TagsTree.tsx @@ -9,12 +9,10 @@ import { TagMerge } from 'src/frontend/containers/Outliner/TagsPanel/TagMerge'; import { useStore } from 'src/frontend/contexts/StoreContext'; import { DnDTagType, useTagDnD } from 'src/frontend/contexts/TagDnDContext'; import { useAction } from 'src/frontend/hooks/mobx'; -import useContextMenu from 'src/frontend/hooks/useContextMenu'; import TagStore from 'src/frontend/stores/TagStore'; import UiStore from 'src/frontend/stores/UiStore'; import { IconSet, Tree } from 'widgets'; -import { Toolbar, ToolbarButton } from 'widgets/menus'; -import ContextMenu from 'src/frontend/components/ContextMenu'; +import { Toolbar, ToolbarButton, useContextMenu } from 'widgets/menus'; import MultiSplitPane, { MultiSplitPaneProps } from 'widgets/MultiSplit/MultiSplitPane'; import { createBranchOnKeyDown, createLeafOnKeyDown, ITreeItem, TreeLabel } from 'widgets/Tree'; import { IExpansionState } from '../../types'; @@ -88,7 +86,6 @@ const Label = (props: ILabelProps) => ); interface ITagItemProps { - showContextMenu: (x: number, y: number, menu: JSX.Element) => void; nodeData: ClientTag; dispatch: React.Dispatch; isEditing: boolean; @@ -124,18 +121,19 @@ const toggleQuery = (nodeData: ClientTag, uiStore: UiStore) => { const DnDHelper = createDragReorderHelper('tag-dnd-preview', DnDTagType); const TagItem = observer((props: ITagItemProps) => { - const { nodeData, dispatch, expansion, isEditing, submit, pos, select, showContextMenu } = props; + const { nodeData, dispatch, expansion, isEditing, submit, pos, select } = props; const { uiStore } = useStore(); const dndData = useTagDnD(); + const show = useContextMenu(); const handleContextMenu = useCallback( (e) => - showContextMenu( + show( e.clientX, e.clientY, , ), - [dispatch, nodeData, pos, showContextMenu], + [dispatch, nodeData, pos, show], ); const handleDragStart = useCallback( @@ -328,7 +326,6 @@ const TagItem = observer((props: ITagItemProps) => { }); interface ITreeData { - showContextMenu: (x: number, y: number, menu: JSX.Element) => void; state: State; dispatch: React.Dispatch; submit: (target: EventTarget & HTMLInputElement) => void; @@ -345,7 +342,6 @@ const TagItemLabel: TreeLabel = ({ pos: number; }) => ( ) => { deletableNode: undefined, mergableNode: undefined, }); - const [contextState, { show, hide }] = useContextMenu(); const dndData = useTagDnD(); /** Header and Footer drop zones of the root node */ @@ -504,13 +499,12 @@ const TagsTree = observer((props: Partial) => { const treeData: ITreeData = useMemo( () => ({ - showContextMenu: show, state, dispatch, submit: submit.current, select, }), - [select, show, state], + [select, state], ); const handleRootAddTag = useAction(() => @@ -632,10 +626,6 @@ const TagsTree = observer((props: Partial) => { {state.mergableNode && ( dispatch(Factory.abortMerge())} /> )} - - - {contextState.menu} - ); }); diff --git a/src/frontend/hooks/useContextMenu.ts b/src/frontend/hooks/useContextMenu.ts deleted file mode 100644 index d64af9e62..000000000 --- a/src/frontend/hooks/useContextMenu.ts +++ /dev/null @@ -1,60 +0,0 @@ -import React, { useRef, useState } from 'react'; - -type Menu = JSX.Element | React.ReactFragment; - -interface IContextMenuConfig { - /** Replaces the menu with the initial menu every time the context menu is - * closed. This is important in cases the menu references a removed object. - * @default true - * */ - resetOnClose?: boolean; - /** The menu that is rendered on mount and also used as replacement if - * 'resetOnClose' is true. - * @default React.ReactFragment - */ - initialMenu?: Menu; -} - -interface IContextMenuState { - open: boolean; - x: number; - y: number; - menu: Menu; -} - -interface IContextMenuMethods { - show: (x: number, y: number, menu: JSX.Element | JSX.Element[]) => void; - hide: () => void; -} - -export default function useContextMenu( - options?: IContextMenuConfig, -): [IContextMenuState, IContextMenuMethods] { - const config = useRef({ - resetOnClose: options?.resetOnClose ?? true, - initialMenu: options?.initialMenu ?? {}, - }); - const [state, dispatch] = useState({ - open: false, - x: 0, - y: 0, - menu: config.current.initialMenu, - }); - - // This is safe to do because React guarantees that the dispatch function's - // identity will stay the same across renders. - const contextMenuMethods = useRef({ - show: (x: number, y: number, menu: JSX.Element | JSX.Element[]) => { - dispatch({ open: true, menu, x, y }); - }, - hide: () => { - if (config.current.resetOnClose) { - dispatch((state) => ({ ...state, menu: config.current.initialMenu, open: false })); - } else { - dispatch((state) => ({ ...state, open: false })); - } - }, - }); - - return [state, contextMenuMethods.current]; -} diff --git a/src/frontend/hooks/usePortal.ts b/src/frontend/hooks/usePortal.ts deleted file mode 100644 index c244387b1..000000000 --- a/src/frontend/hooks/usePortal.ts +++ /dev/null @@ -1,97 +0,0 @@ -import React, { useRef, useEffect } from 'react'; -import { createPortal } from 'react-dom'; -import { useStore } from '../contexts/StoreContext'; -import { observer } from 'mobx-react-lite'; - -// From: https://www.jayfreestone.com/writing/react-portals-with-hooks/ - -/** - * Creates DOM element to be used as React root. - * @returns {HTMLElement} - */ -function createParentElement(id: string, className: string): HTMLElement { - const rootContainer = document.createElement('div'); - rootContainer.setAttribute('id', id); - rootContainer.className = className; - return rootContainer; -} - -/** - * Hook to create a React Portal. - * Automatically handles creating and tearing-down the root elements (no SRR - * makes this trivial), so there is no need to ensure the parent target already - * exists. - * @example - * const target = usePortal(id, [id]); - * return createPortal(children, target); - * @param {String} id The id of the target container, e.g 'modal' or 'spotlight' - * @returns {HTMLElement} The DOM node to use as the Portal target. - */ -function usePortal(id: string, className: string): HTMLElement { - const rootElementRef = useRef(); - - useEffect( - function setupElement() { - // Look for existing target dom element to append to - const existingParent = document.querySelector(`#${id}`) as HTMLElement | null; - // Parent is either a new root or the existing dom element - const parentElement = existingParent ?? createParentElement(id, className); - - if (existingParent === null) { - document.body.appendChild(parentElement); - } else { - existingParent.className = className; - } - - // Add the detached element to the parent - if ( - rootElementRef.current !== undefined && - rootElementRef.current.parentElement !== parentElement - ) { - parentElement.appendChild(rootElementRef.current); - } - - return function removeElement() { - rootElementRef.current?.remove(); - if (parentElement.childElementCount === 0) { - parentElement.remove(); - } - }; - }, - [className, id], - ); - - /** - * It's important we evaluate this lazily: - * - We need first render to contain the DOM element, so it shouldn't happen - * in useEffect. We would normally put this in the constructor(). - * - We can't do 'const rootElemRef = useRef(document.createElement('div))', - * since this will run every single render (that's a lot). - * - We want the ref to consistently point to the same DOM element and only - * ever run once. - * @link https://reactjs.org/docs/hooks-faq.html#how-to-create-expensive-objects-lazily - */ - function getRootElement(): HTMLElement { - if (rootElementRef.current === undefined) { - rootElementRef.current = document.createElement('div'); - } - return rootElementRef.current; - } - - return getRootElement(); -} - -/** - * @example - * - *

Thinking with portals

- *
- */ -export const Portal = observer(({ id, children }: { id: string; children: React.ReactNode }) => { - const { uiStore } = useStore(); - - const target = usePortal(id, uiStore.theme); - return createPortal(children, target); -}); - -export default usePortal; diff --git a/src/frontend/stores/FileStore.ts b/src/frontend/stores/FileStore.ts index 240739e58..880895426 100644 --- a/src/frontend/stores/FileStore.ts +++ b/src/frontend/stores/FileStore.ts @@ -547,7 +547,7 @@ class FileStore { } } - @action private async updateFromBackend(backendFiles: IFile[]): Promise { + @action async updateFromBackend(backendFiles: IFile[]): Promise { if (backendFiles.length === 0) { this.rootStore.uiStore.clearFileSelection(); this.fileListLastModified = new Date(); diff --git a/src/renderer.tsx b/src/renderer.tsx index 2e3533681..4168d34dc 100644 --- a/src/renderer.tsx +++ b/src/renderer.tsx @@ -39,7 +39,7 @@ backend if (IS_PREVIEW_WINDOW) { RendererMessenger.onReceivePreviewFiles( - ({ ids, thumbnailDirectory, viewMethod, activeImgId }) => { + async ({ ids, thumbnailDirectory, viewMethod, activeImgId }) => { rootStore.uiStore.setThumbnailDirectory(thumbnailDirectory); rootStore.uiStore.setMethod(viewMethod); rootStore.uiStore.enableSlideMode(); @@ -50,9 +50,19 @@ if (IS_PREVIEW_WINDOW) { } }); - rootStore.fileStore.fetchFilesByIDs(ids).then(() => { - rootStore.uiStore.setFirstItem((activeImgId && ids.indexOf(activeImgId)) || 0); - }); + const files = await backend.fetchFilesByID(ids); + + // If a file has a location we don't know about (e.g. when a new location was added to the main window), + // re-fetch the locations in the preview window + const hasNewLocation = runInAction(() => + files.some((f) => !rootStore.locationStore.locationList.find((l) => l.id === f.id)), + ); + if (hasNewLocation) { + await rootStore.locationStore.init(); + } + + await rootStore.fileStore.updateFromBackend(files); + rootStore.uiStore.setFirstItem((activeImgId && ids.indexOf(activeImgId)) || 0); }, ); diff --git a/widgets/menus/ContextMenu.tsx b/widgets/menus/ContextMenu.tsx index 3ec7ac8d5..ba6b122bf 100644 --- a/widgets/menus/ContextMenu.tsx +++ b/widgets/menus/ContextMenu.tsx @@ -1,31 +1,51 @@ -import React, { useEffect, useLayoutEffect, useRef } from 'react'; +import React, { useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { usePopover } from '../popovers/usePopover'; -import { MenuProps } from './menus'; +import { Menu, MenuProps } from './menus'; -export interface ContextMenuProps { +export const ContextMenuLayer = ({ children }: { children: React.ReactNode }) => { + const [state, setState] = useState({ isOpen: false, x: 0, y: 0, menu: {[]} }); + const { show, hide } = useRef({ + show: (x: number, y: number, menu: React.ReactElement) => { + setState({ isOpen: true, x, y, menu }); + }, + hide: () => { + setState({ isOpen: false, x: 0, y: 0, menu: {[]} }); + }, + }).current; + + return ( + + {children} + + {state.menu} + + + ); +}; + +export const useContextMenu = () => { + return useContext(ContextMenuContext); +}; + +type ContextMenuActions = (x: number, y: number, menu: React.ReactElement) => void; + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const ContextMenuContext = React.createContext(() => {}); + +const ContextMenuProvider = ContextMenuContext.Provider; + +interface ContextMenuProps { isOpen: boolean; x: number; y: number; - children: React.ReactElement | React.ReactFragment; + children: React.ReactElement; close: () => void; } /** * The classic desktop context menu - * - * Unlike other implementations there is no single context menu added through a - * React portal. This component is driven entirely by the state of your app. - * - * This might seem inconvenient but the upside is that styling has not to be - * re-applied to a portal and that multiple context menus can exist without - * harming performance. In short this component is more inconvenient but allows - * for better composability. - * - * Since it is really annoying to always write out the same lines of code, the - * `useContextMenu` hook can be used to create all the necessary state and - * callbacks which can be used to set the state from deep within a tree. */ -export const ContextMenu = ({ isOpen, x, y, children, close }: ContextMenuProps) => { +const ContextMenu = ({ isOpen, x, y, children, close }: ContextMenuProps) => { const container = useRef(null); const boundingRect = useRef(new DOMRect()); const { style, reference, floating, update } = usePopover('right-start'); @@ -99,7 +119,7 @@ export const ContextMenu = ({ isOpen, x, y, children, close }: ContextMenuProps) onClick={handleClick} onMouseOver={handleMouseOver} > - {isOpen ? children : null} + {children} ); }; diff --git a/widgets/menus/index.ts b/widgets/menus/index.ts index 9d4a11c4a..368e9d344 100644 --- a/widgets/menus/index.ts +++ b/widgets/menus/index.ts @@ -20,7 +20,7 @@ import { IMenuRadioItem, MenuItemLinkProps, } from './menu-items'; -import { ContextMenu } from './ContextMenu'; +import { ContextMenuLayer, useContextMenu } from './ContextMenu'; import { MenuButton } from './MenuButton'; export { @@ -44,4 +44,12 @@ export { import { Toolbar, ToolbarButton, ToolbarSegment, ToolbarSegmentButton } from '../Toolbar'; -export { ContextMenu, ToolbarButton, MenuButton, ToolbarSegment, ToolbarSegmentButton, Toolbar }; +export { + ContextMenuLayer, + useContextMenu, + ToolbarButton, + MenuButton, + ToolbarSegment, + ToolbarSegmentButton, + Toolbar, +};