From aa538a9d5a8d873dadcc7dfef0453046fc0c4f12 Mon Sep 17 00:00:00 2001 From: hummingly <31522351+hummingly@users.noreply.github.com> Date: Thu, 31 Mar 2022 23:36:03 +0200 Subject: [PATCH 1/8] One context menu to rule them all * Back when we still used BlueprintJS, React portals were slow or BlueprintJS' implementation. We talk about seconds to open a context menu. * Furthermore, portals do not inherit styles since they are out of tree :( BUT you can solve that with passing the top level class on creation and always update that. * Back then I was also on an explicit everything trip which is how the useContextMenu hook was born. However, it has its merit if you do not know what the perfect API looks like (which is kinda always the case). * Now I don't hate portals anymore but I still deleted them lol * The context menu will be now a sibling element which solves most overlap issues. * Well, except for tooltips but this is such a weird edge case. Who wants to read tooltips after opening context menus? * Either way the React Context API pretty much fixes all API issues which stem from React fundamental designs (isolated components and and tree like state flow == PITA for graph like state). --- src/frontend/Preview.tsx | 5 +- src/frontend/components/ContextMenu.tsx | 41 +++++--- src/frontend/components/FileTag.tsx | 44 ++++----- src/frontend/components/PopupWindow.tsx | 3 +- .../containers/ContentView/Commands.tsx | 56 ++++++++--- .../containers/ContentView/LayoutSwitcher.tsx | 5 +- src/frontend/containers/ContentView/index.tsx | 56 ++++------- src/frontend/containers/Main.tsx | 37 +++---- .../Outliner/LocationsPanel/index.tsx | 46 ++++----- .../Outliner/SavedSearchesPanel/index.tsx | 53 ++-------- .../Outliner/TagsPanel/TagsTree.tsx | 21 ++-- src/frontend/hooks/useContextMenu.ts | 60 ------------ src/frontend/hooks/usePortal.ts | 97 ------------------- widgets/menus/ContextMenu.tsx | 12 --- 14 files changed, 175 insertions(+), 361 deletions(-) delete mode 100644 src/frontend/hooks/useContextMenu.ts delete mode 100644 src/frontend/hooks/usePortal.ts diff --git a/src/frontend/Preview.tsx b/src/frontend/Preview.tsx index 54b71f78a..7ba8f7e59 100644 --- a/src/frontend/Preview.tsx +++ b/src/frontend/Preview.tsx @@ -10,6 +10,7 @@ import { Toolbar, ToolbarButton } from 'widgets/menus'; import { useWorkerListener } from './image/ThumbnailGeneration'; import SplashScreen from './containers/SplashScreen'; +import { ContextMenuLayer } from './components/ContextMenu'; const PreviewApp = observer(() => { const { uiStore, fileStore } = useStore(); @@ -77,7 +78,9 @@ const PreviewApp = observer(() => { /> - + + + ); diff --git a/src/frontend/components/ContextMenu.tsx b/src/frontend/components/ContextMenu.tsx index 49fe2e85d..f26e0d800 100644 --- a/src/frontend/components/ContextMenu.tsx +++ b/src/frontend/components/ContextMenu.tsx @@ -1,17 +1,34 @@ -import React from 'react'; -import { ContextMenu as BaseContextMenu } from 'widgets/menus'; -import { ContextMenuProps } from 'widgets/menus/ContextMenu'; -import { Portal } from '../hooks/usePortal'; +import React, { useContext, useRef, useState } from 'react'; +import { ContextMenu, MenuProps } from 'widgets/menus'; + +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; + +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; -// TODO: Merge with useContextMenu hook. -const ContextMenu = ({ isOpen, x, y, close, children }: ContextMenuProps) => { return ( - - - {children} - - + <> + {children} + + {state.menu} + + ); }; -export default ContextMenu; +export const useContextMenu = () => { + return useContext(ContextMenuContext); +}; diff --git a/src/frontend/components/FileTag.tsx b/src/frontend/components/FileTag.tsx index 321d77929..0c31562c4 100644 --- a/src/frontend/components/FileTag.tsx +++ b/src/frontend/components/FileTag.tsx @@ -5,11 +5,10 @@ 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 { useContextMenu } from './ContextMenu'; interface IFileTagProp { file: ClientFile; @@ -18,8 +17,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 e1a8c1eb5..8dd98ae7f 100644 --- a/src/frontend/components/PopupWindow.tsx +++ b/src/frontend/components/PopupWindow.tsx @@ -50,7 +50,8 @@ const PopupWindow: React.FC = (props) => { if (win) { return ReactDOM.createPortal( <> - {props.children} + {props.children} + , containerEl, ); diff --git a/src/frontend/containers/ContentView/Commands.tsx b/src/frontend/containers/ContentView/Commands.tsx index 35e514682..e91727528 100644 --- a/src/frontend/containers/ContentView/Commands.tsx +++ b/src/frontend/containers/ContentView/Commands.tsx @@ -3,10 +3,14 @@ import React from 'react'; import { useEffect } from 'react'; import { ClientFile } from 'src/entities/File'; import { ClientTag } from 'src/entities/Tag'; +import { useContextMenu } from 'src/frontend/components/ContextMenu'; import { useStore } from 'src/frontend/contexts/StoreContext'; import { DnDAttribute, DnDTagType, useTagDnD } from 'src/frontend/contexts/TagDnDContext'; +import { useAction } from 'src/frontend/hooks/mobx'; import { RendererMessenger } from 'src/Messaging'; -import { MenuDivider } from 'widgets/menus'; +import { IconSet } from 'widgets/Icons'; +import { Menu, MenuDivider, MenuSubItem } from 'widgets/menus'; +import { LayoutMenuItems, SortMenuItems } from '../AppToolbar/Menus'; import { ExternalAppMenuItems, FileTagMenuItems, @@ -142,10 +146,34 @@ 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(); + const showContextMenu = useAction( + (x: number, y: number, fileMenu: JSX.Element, externalMenu: JSX.Element) => { + show( + x, + y, + + {fileMenu} + {!uiStore.isSlideMode && ( + <> + + + + + + + + + )} + + {externalMenu} + , + ); + }, + ); useEffect(() => { const handleSelect = action((event: Event) => { @@ -167,10 +195,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 +210,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 +229,12 @@ export function useCommandHandler( const handleSlideContextMenu = 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); 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/index.tsx b/src/frontend/containers/ContentView/index.tsx index 9ed64af38..6ca658272 100644 --- a/src/frontend/containers/ContentView/index.tsx +++ b/src/frontend/containers/ContentView/index.tsx @@ -1,13 +1,11 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { observer } from 'mobx-react-lite'; import { useStore } from '../../contexts/StoreContext'; -import useContextMenu from '../../hooks/useContextMenu'; - import { IconSet } from 'widgets'; -import { MenuSubItem, Menu, MenuChild, MenuDivider } from 'widgets/menus'; -import ContextMenu from 'src/frontend/components/ContextMenu'; +import { MenuSubItem, Menu } from 'widgets/menus'; +import { useContextMenu } from 'src/frontend/components/ContextMenu'; import Placeholder from './Placeholder'; import Layout from './LayoutSwitcher'; @@ -37,20 +35,28 @@ const ContentView = observer(() => { const Content = observer(() => { const { fileStore, uiStore } = useStore(); const dndData = useTagDnD(); - const [contextState, { show, hide }] = useContextMenu({ initialMenu: [<>, <>] }); - const { open, x, y, menu } = contextState; - const [fileMenu, externalMenu] = menu as [MenuChild, MenuChild]; const { fileList } = fileStore; const [contentRect, setContentRect] = useState({ width: 1, height: 1 }); const container = useRef(null); const isMaximized = useIsWindowMaximized(); - const handleContextMenu = useCallback( - (e: React.MouseEvent) => { - show(e.clientX, e.clientY, []); - }, - [show], - ); + const show = useContextMenu(); + const handleContextMenu = useAction((e: React.MouseEvent) => { + if (!uiStore.isSlideMode) { + show( + e.clientX, + e.clientY, + + + + + + + + , + ); + } + }); const resizeObserver = useRef( new ResizeObserver((entries) => { @@ -101,27 +107,7 @@ const Content = observer(() => { onClick={clearFileSelection} onKeyDown={handleKeyDown} > - - - - - {fileMenu} - {!uiStore.isSlideMode && ( - <> - {fileMenu && } - - - - - - - - )} - {externalMenu && } - {externalMenu} - - - + ); diff --git a/src/frontend/containers/Main.tsx b/src/frontend/containers/Main.tsx index 4ac3c1613..0a2877347 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 '../components/ContextMenu'; 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/index.tsx b/src/frontend/containers/Outliner/LocationsPanel/index.tsx index 54fbfbe16..aba45cd93 100644 --- a/src/frontend/containers/Outliner/LocationsPanel/index.tsx +++ b/src/frontend/containers/Outliner/LocationsPanel/index.tsx @@ -12,13 +12,12 @@ 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 { useContextMenu } from 'src/frontend/components/ContextMenu'; import MultiSplitPane, { MultiSplitPaneProps } from 'widgets/MultiSplit/MultiSplitPane'; import { Callout } from 'widgets/notifications'; import { createBranchOnKeyDown, ITreeItem } from 'widgets/Tree'; @@ -80,7 +79,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 +198,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 +289,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 +434,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 +447,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 +503,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(); @@ -598,11 +597,7 @@ const LocationsPanel = observer((props: Partial) => { } {...props} > - + {isEmpty && Click + to choose a location.} @@ -625,9 +620,6 @@ const LocationsPanel = observer((props: Partial) => { onClose={() => setExcludableSubLocation(undefined)} /> )} - - {contextState.menu} - ); }); diff --git a/src/frontend/containers/Outliner/SavedSearchesPanel/index.tsx b/src/frontend/containers/Outliner/SavedSearchesPanel/index.tsx index 0d0d9cf0f..a4b454c2e 100644 --- a/src/frontend/containers/Outliner/SavedSearchesPanel/index.tsx +++ b/src/frontend/containers/Outliner/SavedSearchesPanel/index.tsx @@ -12,10 +12,9 @@ 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 { useContextMenu } from 'src/frontend/components/ContextMenu'; import MultiSplitPane, { MultiSplitPaneProps } from 'widgets/MultiSplit/MultiSplitPane'; import { Callout } from 'widgets/notifications'; import { Toolbar, ToolbarButton } from 'widgets/Toolbar'; @@ -31,7 +30,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 +119,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 +139,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 +256,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 +294,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 +312,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 +365,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 +405,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..106e8f507 100644 --- a/src/frontend/containers/Outliner/TagsPanel/TagsTree.tsx +++ b/src/frontend/containers/Outliner/TagsPanel/TagsTree.tsx @@ -9,12 +9,11 @@ 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 { useContextMenu } from 'src/frontend/components/ContextMenu'; import MultiSplitPane, { MultiSplitPaneProps } from 'widgets/MultiSplit/MultiSplitPane'; import { createBranchOnKeyDown, createLeafOnKeyDown, ITreeItem, TreeLabel } from 'widgets/Tree'; import { IExpansionState } from '../../types'; @@ -88,7 +87,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 +122,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 +327,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 +343,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 +500,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 +627,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/widgets/menus/ContextMenu.tsx b/widgets/menus/ContextMenu.tsx index 3ec7ac8d5..3e50bdc52 100644 --- a/widgets/menus/ContextMenu.tsx +++ b/widgets/menus/ContextMenu.tsx @@ -12,18 +12,6 @@ export interface ContextMenuProps { /** * 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 container = useRef(null); From 19e008fa478719bada8659550e6a39ee922c315c Mon Sep 17 00:00:00 2001 From: hummingly <31522351+hummingly@users.noreply.github.com> Date: Fri, 1 Apr 2022 00:02:42 +0200 Subject: [PATCH 2/8] Inline context menus --- .../containers/ContentView/Commands.tsx | 44 ++++++++++--------- .../containers/ContentView/GalleryItem.tsx | 6 +-- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/src/frontend/containers/ContentView/Commands.tsx b/src/frontend/containers/ContentView/Commands.tsx index e91727528..ea0099de2 100644 --- a/src/frontend/containers/ContentView/Commands.tsx +++ b/src/frontend/containers/ContentView/Commands.tsx @@ -6,7 +6,6 @@ import { ClientTag } from 'src/entities/Tag'; import { useContextMenu } from 'src/frontend/components/ContextMenu'; import { useStore } from 'src/frontend/contexts/StoreContext'; import { DnDAttribute, DnDTagType, useTagDnD } from 'src/frontend/contexts/TagDnDContext'; -import { useAction } from 'src/frontend/hooks/mobx'; import { RendererMessenger } from 'src/Messaging'; import { IconSet } from 'widgets/Icons'; import { Menu, MenuDivider, MenuSubItem } from 'widgets/menus'; @@ -150,32 +149,32 @@ export function useCommandHandler( const dndData = useTagDnD(); const { uiStore } = useStore(); const show = useContextMenu(); - const showContextMenu = useAction( - (x: number, y: number, fileMenu: JSX.Element, externalMenu: JSX.Element) => { + + useEffect(() => { + const showContextMenu = ( + x: number, + y: number, + fileMenu: JSX.Element, + externalMenu: JSX.Element, + ) => { show( x, y, {fileMenu} - {!uiStore.isSlideMode && ( - <> - - - - - - - - - )} + + + + + + + {externalMenu} , ); - }, - ); + }; - useEffect(() => { const handleSelect = action((event: Event) => { event.stopPropagation(); const { file, selectAdditive, selectRange } = (event as CommandHandlerEvent) @@ -229,11 +228,14 @@ export function useCommandHandler( const handleSlideContextMenu = action((event: Event) => { event.stopPropagation(); const { file, x, y } = (event as CommandHandlerEvent).detail; - showContextMenu( + show( x, y, - file.isBroken ? : , - , + + {file.isBroken ? : } + + + , ); if (!uiStore.fileSelection.has(file)) { // replace selection with context menu, like Windows file explorer @@ -309,7 +311,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)} /> ); }, From ed98b0ad0f0b727075aa49feaf5a9ccbb1cf97f3 Mon Sep 17 00:00:00 2001 From: hummingly <31522351+hummingly@users.noreply.github.com> Date: Fri, 1 Apr 2022 10:09:28 +0200 Subject: [PATCH 3/8] Move context components to widgets module --- src/frontend/Preview.tsx | 3 +- src/frontend/components/ContextMenu.tsx | 34 -------------- src/frontend/components/FileTag.tsx | 3 +- .../containers/ContentView/Commands.tsx | 3 +- src/frontend/containers/ContentView/index.tsx | 3 +- src/frontend/containers/Main.tsx | 2 +- .../Outliner/LocationsPanel/index.tsx | 3 +- .../Outliner/SavedSearchesPanel/index.tsx | 3 +- .../Outliner/TagsPanel/TagsTree.tsx | 3 +- widgets/menus/ContextMenu.tsx | 44 ++++++++++++++++--- widgets/menus/index.ts | 12 ++++- 11 files changed, 56 insertions(+), 57 deletions(-) delete mode 100644 src/frontend/components/ContextMenu.tsx diff --git a/src/frontend/Preview.tsx b/src/frontend/Preview.tsx index 7ba8f7e59..41ef643f8 100644 --- a/src/frontend/Preview.tsx +++ b/src/frontend/Preview.tsx @@ -6,11 +6,10 @@ 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'; -import { ContextMenuLayer } from './components/ContextMenu'; const PreviewApp = observer(() => { const { uiStore, fileStore } = useStore(); diff --git a/src/frontend/components/ContextMenu.tsx b/src/frontend/components/ContextMenu.tsx deleted file mode 100644 index f26e0d800..000000000 --- a/src/frontend/components/ContextMenu.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React, { useContext, useRef, useState } from 'react'; -import { ContextMenu, MenuProps } from 'widgets/menus'; - -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; - -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); -}; diff --git a/src/frontend/components/FileTag.tsx b/src/frontend/components/FileTag.tsx index 0c31562c4..cda4a048c 100644 --- a/src/frontend/components/FileTag.tsx +++ b/src/frontend/components/FileTag.tsx @@ -7,8 +7,7 @@ import { useStore } from '../contexts/StoreContext'; import { TagSelector } from './TagSelector'; import { FileTagMenuItems } from '../containers/ContentView/menu-items'; import { ClientTag } from 'src/entities/Tag'; -import { Menu } from 'widgets/menus'; -import { useContextMenu } from './ContextMenu'; +import { Menu, useContextMenu } from 'widgets/menus'; interface IFileTagProp { file: ClientFile; diff --git a/src/frontend/containers/ContentView/Commands.tsx b/src/frontend/containers/ContentView/Commands.tsx index ea0099de2..d4dde3cb5 100644 --- a/src/frontend/containers/ContentView/Commands.tsx +++ b/src/frontend/containers/ContentView/Commands.tsx @@ -3,12 +3,11 @@ import React from 'react'; import { useEffect } from 'react'; import { ClientFile } from 'src/entities/File'; import { ClientTag } from 'src/entities/Tag'; -import { useContextMenu } from 'src/frontend/components/ContextMenu'; import { useStore } from 'src/frontend/contexts/StoreContext'; import { DnDAttribute, DnDTagType, useTagDnD } from 'src/frontend/contexts/TagDnDContext'; import { RendererMessenger } from 'src/Messaging'; import { IconSet } from 'widgets/Icons'; -import { Menu, MenuDivider, MenuSubItem } from 'widgets/menus'; +import { Menu, MenuDivider, MenuSubItem, useContextMenu } from 'widgets/menus'; import { LayoutMenuItems, SortMenuItems } from '../AppToolbar/Menus'; import { ExternalAppMenuItems, diff --git a/src/frontend/containers/ContentView/index.tsx b/src/frontend/containers/ContentView/index.tsx index 6ca658272..62707f0e7 100644 --- a/src/frontend/containers/ContentView/index.tsx +++ b/src/frontend/containers/ContentView/index.tsx @@ -4,8 +4,7 @@ import { observer } from 'mobx-react-lite'; import { useStore } from '../../contexts/StoreContext'; import { IconSet } from 'widgets'; -import { MenuSubItem, Menu } from 'widgets/menus'; -import { useContextMenu } from 'src/frontend/components/ContextMenu'; +import { MenuSubItem, Menu, useContextMenu } from 'widgets/menus'; import Placeholder from './Placeholder'; import Layout from './LayoutSwitcher'; diff --git a/src/frontend/containers/Main.tsx b/src/frontend/containers/Main.tsx index 0a2877347..c528a194a 100644 --- a/src/frontend/containers/Main.tsx +++ b/src/frontend/containers/Main.tsx @@ -9,7 +9,7 @@ import AppToolbar from './AppToolbar'; import ContentView from './ContentView'; import Outliner from './Outliner'; import { useAction } from '../hooks/mobx'; -import { ContextMenuLayer } from '../components/ContextMenu'; +import { ContextMenuLayer } from 'widgets/menus'; const Main = () => { const { uiStore } = useStore(); diff --git a/src/frontend/containers/Outliner/LocationsPanel/index.tsx b/src/frontend/containers/Outliner/LocationsPanel/index.tsx index aba45cd93..7161f2ec1 100644 --- a/src/frontend/containers/Outliner/LocationsPanel/index.tsx +++ b/src/frontend/containers/Outliner/LocationsPanel/index.tsx @@ -16,8 +16,7 @@ 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 { useContextMenu } 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'; diff --git a/src/frontend/containers/Outliner/SavedSearchesPanel/index.tsx b/src/frontend/containers/Outliner/SavedSearchesPanel/index.tsx index a4b454c2e..a038250b6 100644 --- a/src/frontend/containers/Outliner/SavedSearchesPanel/index.tsx +++ b/src/frontend/containers/Outliner/SavedSearchesPanel/index.tsx @@ -13,8 +13,7 @@ import { } from 'src/frontend/contexts/TagDnDContext'; import { useAutorun } from 'src/frontend/hooks/mobx'; import { IconSet } from 'widgets/Icons'; -import { Menu, MenuItem } from 'widgets/menus'; -import { useContextMenu } 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'; diff --git a/src/frontend/containers/Outliner/TagsPanel/TagsTree.tsx b/src/frontend/containers/Outliner/TagsPanel/TagsTree.tsx index 106e8f507..393215646 100644 --- a/src/frontend/containers/Outliner/TagsPanel/TagsTree.tsx +++ b/src/frontend/containers/Outliner/TagsPanel/TagsTree.tsx @@ -12,8 +12,7 @@ import { useAction } from 'src/frontend/hooks/mobx'; 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 { useContextMenu } 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'; diff --git a/widgets/menus/ContextMenu.tsx b/widgets/menus/ContextMenu.tsx index 3e50bdc52..ba6b122bf 100644 --- a/widgets/menus/ContextMenu.tsx +++ b/widgets/menus/ContextMenu.tsx @@ -1,19 +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 */ -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'); @@ -87,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, +}; From 1346f07b8946653bb675e2c108188ef1fc2d860b Mon Sep 17 00:00:00 2001 From: Remi van der Laan Date: Sat, 2 Apr 2022 17:17:08 +0200 Subject: [PATCH 4/8] Fix for focus issue in searchbar --- src/frontend/components/TagSelector.tsx | 12 ++++++++---- src/frontend/containers/AppToolbar/Searchbar.tsx | 4 +++- 2 files changed, 11 insertions(+), 5 deletions(-) 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" /> From 74cf4eb0a895d1d23417823d040e45dd048e3391 Mon Sep 17 00:00:00 2001 From: Remi van der Laan Date: Sat, 2 Apr 2022 17:17:31 +0200 Subject: [PATCH 5/8] Fix for new location incorrectly detected as sub-dir of an existing location --- src/frontend/containers/Outliner/LocationsPanel/index.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/frontend/containers/Outliner/LocationsPanel/index.tsx b/src/frontend/containers/Outliner/LocationsPanel/index.tsx index 7161f2ec1..af2ed0e4b 100644 --- a/src/frontend/containers/Outliner/LocationsPanel/index.tsx +++ b/src/frontend/containers/Outliner/LocationsPanel/index.tsx @@ -535,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.', @@ -549,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({ From 74111b66442d440fbbe5f8ab99ea4ee674b841b5 Mon Sep 17 00:00:00 2001 From: Remi van der Laan Date: Sat, 2 Apr 2022 18:08:00 +0200 Subject: [PATCH 6/8] Fix for preview window for images in a newly added location --- .../containers/ContentView/Placeholder.tsx | 47 ++++++++++++++++++- src/frontend/stores/FileStore.ts | 2 +- src/renderer.tsx | 18 +++++-- 3 files changed, 61 insertions(+), 6 deletions(-) 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

+ +
+ +