import * as React from 'react'; import { DragEventHandler, MouseEventHandler, useCallback, useMemo, useRef } from 'react'; import { ItemClickListener, ItemDragListener, ListItem, ListItemType } from '../types'; import TagItem from '../listItemComponents/TagItem'; import { Dispatch } from 'redux'; import { clipboard } from 'electron'; import type { MenuItem as MenuItemType } from 'electron'; import { getTrashFolderId } from '@joplin/lib/services/trash'; import BaseModel, { ModelType } from '@joplin/lib/BaseModel'; import Tag from '@joplin/lib/models/Tag'; import { _ } from '@joplin/lib/locale'; import { substrWithEllipsis } from '@joplin/lib/string-utils'; import Folder from '@joplin/lib/models/Folder'; import bridge from '../../../services/bridge'; import MenuUtils from '@joplin/lib/services/commands/MenuUtils'; import CommandService from '@joplin/lib/services/CommandService'; import { FolderEntity } from '@joplin/lib/services/database/types'; import InteropService from '@joplin/lib/services/interop/InteropService'; import InteropServiceHelper from '../../../InteropServiceHelper'; import Setting from '@joplin/lib/models/Setting'; import PerFolderSortOrderService from '@joplin/lib/services/sortOrder/PerFolderSortOrderService'; import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils'; import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer'; import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types'; import FolderItem from '../listItemComponents/FolderItem'; import Logger from '@joplin/utils/Logger'; import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop'; import HeaderItem from '../listItemComponents/HeaderItem'; import AllNotesItem from '../listItemComponents/AllNotesItem'; import ListItemWrapper, { ItemSelectionState } from '../listItemComponents/ListItemWrapper'; import { focus } from '@joplin/lib/utils/focusHandler'; import shim from '@joplin/lib/shim'; import useOnItemClick from './useOnItemClick'; import { ShareType, StateShare } from '@joplin/lib/services/share/reducer'; const Menu = bridge().Menu; const MenuItem: typeof MenuItemType = bridge().MenuItem; const logger = Logger.create('useOnRenderItem'); interface Props { dispatch: Dispatch; themeId: number; plugins: PluginStates; shares: StateShare[]; folders: FolderEntity[]; collapsedFolderIds: string[]; containerRef: React.RefObject; selectedIndex: number; selectedIndexes: number[]; listItems: ListItem[]; } type ItemContextMenuListener = MouseEventHandler; const menuUtils = new MenuUtils(CommandService.instance()); // Checks whether an element is at least partially visible within a scrollable // container by comparing their bounding rectangles. const isElementVisibleInContainer = (element: HTMLElement, container: HTMLElement) => { const elementRect = element.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); return elementRect.bottom > containerRect.top && elementRect.top < containerRect.bottom; }; const focusListItem = (item: HTMLElement|null) => { if (item) { const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null; const itemList = item.closest('.item-list'); const activeTreeItem = activeElement?.closest('[role="treeitem"]'); const focusWasLost = activeElement === document.body; // If the currently focused element is a tree item inside the same list, // the user is navigating with the keyboard — always allow focus to move // to the newly selected item so arrow-key scrolling is not interrupted. const isKeyboardNavigating = !!activeTreeItem && itemList?.contains(activeTreeItem); // Avoid disturbing scroll while user is manually scrolling through the list. // However, if focus was lost (activeElement -> ), or the user is // navigating with the keyboard, allow re-focusing even when the selected // item is currently out of view. if (itemList instanceof HTMLElement && !isElementVisibleInContainer(item, itemList) && !focusWasLost && !isKeyboardNavigating) { return; } // Move focus only if needed: either focus was lost, or selection changed // to a different tree item. if (focusWasLost || activeTreeItem !== item) { // preventScroll: true avoids a secondary scroll caused by the focus() call // itself when the item is near the edge of the visible area. focus('useOnRenderItem', item, { preventScroll: true }); } } }; const noFocusListItem = () => {}; const folderCommandToMenuItem = (commandId: string, folderIds: string|string[]) => { const options = Array.isArray(folderIds) ? { commandFolderIds: folderIds } : { commandFolderId: folderIds }; return new MenuItem(menuUtils.commandToStatefulMenuItem(commandId, folderIds, options)); }; const useOnRenderItem = (props: Props) => { const pluginsRef = useRef(null); pluginsRef.current = props.plugins; const foldersRef = useRef(null); foldersRef.current = props.folders; const onTagDrop_: DragEventHandler = useCallback(async event => { const tagId = event.currentTarget.getAttribute('data-tag-id'); const dt = event.dataTransfer; if (!dt) return; if (dt.types.indexOf('text/x-jop-note-ids') >= 0) { event.preventDefault(); const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids')); for (let i = 0; i < noteIds.length; i++) { await Tag.addNote(tagId, noteIds[i]); } } }, []); const selectedIndexesRef = useRef(props.selectedIndexes); selectedIndexesRef.current = props.selectedIndexes; const itemsRef = useRef(props.listItems); itemsRef.current = props.listItems; const getSelectedIds = useCallback(() => { return selectedIndexesRef.current.map(index => { const item = itemsRef.current[index]; if (item.kind === ListItemType.Folder) { return item.folder.id; } else if (item.kind === ListItemType.Tag) { return item.tag.id; } return null; }).filter(id => !!id); }, []); const onItemClick = useOnItemClick({ dispatch: props.dispatch, selectedIndexesRef, itemsRef }); const onItemContextMenu: ItemContextMenuListener = useCallback(async event => { const itemId = event.currentTarget.getAttribute('data-id'); if (itemId === Folder.conflictFolderId()) return; const itemType = Number(event.currentTarget.getAttribute('data-type')); if (!itemId || !itemType) throw new Error('No data on element'); let itemIds = [itemId]; const itemIndex = Number(event.currentTarget.getAttribute('data-index')); if (selectedIndexesRef.current.includes(itemIndex)) { itemIds = getSelectedIds(); } let deleteMessage = ''; const deleteButtonLabel = _('Remove'); if (itemType === BaseModel.TYPE_TAG) { if (itemIds.length === 1) { const tag = await Tag.load(itemId); deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32)); } else { deleteMessage = _('Remove %d tags from all notes? This cannot be undone.', itemIds.length); } } else if (itemType === BaseModel.TYPE_SEARCH) { deleteMessage = _('Remove this search from the sidebar?'); } const menu = new Menu(); if (itemId === getTrashFolderId()) { menu.append( new MenuItem(menuUtils.commandToStatefulMenuItem('emptyTrash')), ); menu.popup({ window: bridge().activeWindow() }); return; } let item = null; if (itemType === BaseModel.TYPE_FOLDER) { item = BaseModel.byId(foldersRef.current, itemId); } const isDeleted = item ? !!item.deleted_time : false; if (!isDeleted) { const isDecryptedFolder = itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied; if (isDecryptedFolder && itemIds.length === 1) { menu.append(folderCommandToMenuItem('newFolder', itemId)); } if (itemType === BaseModel.TYPE_FOLDER) { menu.append(folderCommandToMenuItem('deleteFolder', itemIds)); } else { menu.append( new MenuItem({ label: deleteButtonLabel, click: async () => { const ok = bridge().showConfirmMessageBox(deleteMessage, { buttons: [deleteButtonLabel, _('Cancel')], defaultId: 1, }); if (!ok) return; if (itemType === BaseModel.TYPE_TAG) { for (const itemId of itemIds) { await Tag.untagAll(itemId); } } else if (itemType === BaseModel.TYPE_SEARCH) { props.dispatch({ type: 'SEARCH_DELETE', id: itemId, }); } }, }), ); } if (isDecryptedFolder) { const whenClause = CommandService.instance().currentWhenClauseContext({ commandFolderIds: itemIds }); menu.append(new MenuItem({ ...menuUtils.commandToStatefulMenuItem('moveToFolder', itemIds), // By default, moveToFolder's enabled condition is based on the selected notes. However, the right-click // menu item applies to folders. For now, use a custom condition: enabled: !whenClause.foldersIncludeReadOnly, })); } if (isDecryptedFolder && itemIds.length === 1) { menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId }, { commandFolderId: itemId }))); menu.append(new MenuItem({ type: 'separator' })); const exportMenu = new Menu(); const ioService = InteropService.instance(); const ioModules = ioService.modules(); for (let i = 0; i < ioModules.length; i++) { const module = ioModules[i]; if (module.type !== 'exporter') continue; exportMenu.append( new MenuItem({ label: module.fullLabel(), click: async () => { await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: itemIds, plugins: pluginsRef.current }); }, }), ); } // Only show the share/leave share actions for top-level folders const shareFolderItem = folderCommandToMenuItem('showShareFolderDialog', itemId); if (shareFolderItem.enabled) menu.append(shareFolderItem); const leaveSharedFolderItem = folderCommandToMenuItem('leaveSharedFolder', itemId); if (leaveSharedFolderItem.enabled) menu.append(leaveSharedFolderItem); const publishFolderItem = folderCommandToMenuItem('showPublishFolderDialog', itemId); if (publishFolderItem.enabled) menu.append(publishFolderItem); menu.append( new MenuItem({ label: _('Export'), submenu: exportMenu, }), ); if (Setting.value('notes.perFolderSortOrderEnabled')) { menu.append(new MenuItem({ ...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId, { commandFolderId: itemId }), type: 'checkbox', checked: PerFolderSortOrderService.isSet(itemId), })); } } if (itemType === BaseModel.TYPE_FOLDER && itemIds.length === 1) { menu.append( new MenuItem({ label: _('Copy external link'), click: () => { clipboard.writeText(getFolderCallbackUrl(itemId)); }, }), ); } if (itemType === BaseModel.TYPE_TAG && itemIds.length === 1) { menu.append(new MenuItem( menuUtils.commandToStatefulMenuItem('renameTag', itemId), )); menu.append( new MenuItem({ label: _('Copy external link'), click: () => { clipboard.writeText(getTagCallbackUrl(itemId)); }, }), ); } const pluginViews = pluginUtils.viewsByType(pluginsRef.current, 'menuItem'); for (const view of pluginViews) { const location = view.location; if (itemType === ModelType.Tag && location === MenuItemLocation.TagContextMenu) { menu.append( new MenuItem(menuUtils.commandToStatefulMenuItem(view.commandName, itemId)), ); } else if (itemType === ModelType.Folder && location === MenuItemLocation.FolderContextMenu) { menu.append(folderCommandToMenuItem(view.commandName, itemId)); } } } else { if (itemType === BaseModel.TYPE_FOLDER) { menu.append(folderCommandToMenuItem('restoreFolder', itemIds)); } } menu.popup({ window: bridge().activeWindow() }); }, [props.dispatch, pluginsRef, getSelectedIds]); const onFolderDragStart_: ItemDragListener = useCallback(event => { const folderId = event.currentTarget.getAttribute('data-folder-id'); if (!folderId) return; let itemIds = [folderId]; const itemIndex = Number(event.currentTarget.getAttribute('data-index')); if (selectedIndexesRef.current.includes(itemIndex)) { itemIds = getSelectedIds(); } event.dataTransfer.setDragImage(new Image(), 1, 1); event.dataTransfer.clearData(); event.dataTransfer.setData('text/x-jop-folder-ids', JSON.stringify(itemIds)); }, [getSelectedIds]); const onFolderDragOver_: ItemDragListener = useCallback(event => { if (event.dataTransfer.types.indexOf('text/x-jop-note-ids') >= 0) event.preventDefault(); if (event.dataTransfer.types.indexOf('text/x-jop-folder-ids') >= 0) event.preventDefault(); }, []); const onFolderDrop_: ItemDragListener = useCallback(async event => { const folderId = event.currentTarget.getAttribute('data-folder-id'); const dt = event.dataTransfer; if (!dt) return; // folderId can be NULL when dropping on the sidebar Notebook header. In that case, it's used // to put the dropped folder at the root. But for notes, folderId needs to always be defined // since there's no such thing as a root note. try { if (dt.types.indexOf('text/x-jop-note-ids') >= 0) { event.preventDefault(); if (!folderId) return; const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids')); await onFolderDrop(noteIds, [], folderId); } else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) { event.preventDefault(); const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids')); await onFolderDrop([], folderIds, folderId); } } catch (error) { logger.error(error); await shim.showErrorDialog(error.message); } }, []); const onFolderToggleClick_: ItemClickListener = useCallback(event => { const folderId = event.currentTarget.getAttribute('data-folder-id'); props.dispatch({ type: 'FOLDER_TOGGLE', id: folderId, }); }, [props.dispatch]); // If at least one of the folder has an icon, then we display icons for all // folders (those without one will get the default icon). This is so that // visual alignment is correct for all folders, otherwise the folder tree // looks messy. const showFolderIcons = useMemo(() => { return Folder.shouldShowFolderIcons(props.folders); }, [props.folders]); const publishedFolderIds = useMemo(() => { const output = new Set(props.shares.filter(share => share.type === ShareType.PublishedFolder).map(share => share.folder_id)); for (const folder of props.folders) { if (folder.is_shared && !folder.share_id) output.add(folder.id); } let size = 0; while (size !== output.size) { size = output.size; for (const folder of props.folders) { if (output.has(folder.parent_id)) output.add(folder.id); } } return output; }, [props.folders, props.shares]); const itemCount = props.listItems.length; return useCallback((item: ListItem, index: number) => { const primarySelected = props.selectedIndex === index; const selected = primarySelected || props.selectedIndexes.includes(index); const selectionState: ItemSelectionState = { primarySelected, selected, multipleItemsSelected: props.selectedIndexes.length > 1, }; const sidebarContainsFocus = props.containerRef.current?.contains(document.activeElement); // Focus moves to when the previously-focused element is removed // from the DOM (e.g. scrolled out of the virtualized list). We still // want to restore focus to the newly-selected item in that case. const focusLostFromDom = document.activeElement === document.body; const focusInList = document.hasFocus() && (sidebarContainsFocus || focusLostFromDom); const anchorRef = (focusInList && primarySelected) ? focusListItem : noFocusListItem; if (item.kind === ListItemType.Tag) { const tag = item.tag; return ; } else if (item.kind === ListItemType.Folder) { const folder = item.folder; const isExpanded = props.collapsedFolderIds.indexOf(folder.id) < 0; let noteCount = (folder as FolderEntity & { note_count?: number }).note_count; // For now hide the count for folders in the trash because it doesn't work and getting it to // work would be tricky. if (folder.deleted_time || folder.id === getTrashFolderId()) noteCount = 0; // Thunderbird count: Subtract children note_count from parent folder if it expanded. if (isExpanded) { for (let i = 0; i < props.folders.length; i++) { if (props.folders[i].parent_id === folder.id) { noteCount -= (props.folders[i] as FolderEntity & { note_count?: number }).note_count; } } } return ; } else if (item.kind === ListItemType.Header) { return ; } else if (item.kind === ListItemType.AllNotes) { return ; } else if (item.kind === ListItemType.Spacer) { return (
); } else { const exhaustivenessCheck: never = item; return exhaustivenessCheck; } }, [ onItemClick, onFolderDragOver_, onFolderDragStart_, onFolderDrop_, onFolderToggleClick_, onItemContextMenu, onTagDrop_, props.collapsedFolderIds, props.folders, publishedFolderIds, showFolderIcons, props.selectedIndex, props.selectedIndexes, props.containerRef, itemCount, ]); }; export default useOnRenderItem;