import * as React from 'react'; import { useEffect, useRef, useCallback, useMemo } from 'react'; import styled, { css } from 'styled-components'; import shim from '@joplin/lib/shim'; import { StyledRoot, StyledAddButton, StyledShareIcon, StyledHeader, StyledHeaderIcon, StyledAllNotesIcon, StyledHeaderLabel, StyledListItem, StyledListItemAnchor, StyledExpandLink, StyledNoteCount, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton } from './styles'; import { ButtonLevel } from '../Button/Button'; import CommandService from '@joplin/lib/services/CommandService'; import InteropService from '@joplin/lib/services/interop/InteropService'; import Synchronizer from '@joplin/lib/Synchronizer'; import Setting from '@joplin/lib/models/Setting'; import MenuUtils from '@joplin/lib/services/commands/MenuUtils'; import InteropServiceHelper from '../../InteropServiceHelper'; import { _ } from '@joplin/lib/locale'; import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer'; import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types'; import { AppState } from '../../app.reducer'; import { ModelType } from '@joplin/lib/BaseModel'; import BaseModel from '@joplin/lib/BaseModel'; import Folder from '@joplin/lib/models/Folder'; import Note from '@joplin/lib/models/Note'; import Tag from '@joplin/lib/models/Tag'; import Logger from '@joplin/utils/Logger'; import { FolderEntity, FolderIcon, FolderIconType } from '@joplin/lib/services/database/types'; import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext'; import { store } from '@joplin/lib/reducer'; import PerFolderSortOrderService from '../../services/sortOrder/PerFolderSortOrderService'; import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils'; import FolderIconBox from '../FolderIconBox'; import { Theme } from '@joplin/lib/themes/type'; import { RuntimeProps } from './commands/focusElementSideBar'; const { connect } = require('react-redux'); const shared = require('@joplin/lib/components/shared/side-menu-shared.js'); const { themeStyle } = require('@joplin/lib/theme'); const bridge = require('@electron/remote').require('./bridge').default; const Menu = bridge().Menu; const MenuItem = bridge().MenuItem; const { substrWithEllipsis } = require('@joplin/lib/string-utils'); const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids'); const { clipboard } = require('electron'); const logger = Logger.create('Sidebar'); // Workaround sidebar rendering bug on Linux Intel GPU. // https://github.com/laurent22/joplin/issues/7506 const StyledSpanFix = styled.span` ${shim.isLinux() && css` position: relative; `} `; interface Props { themeId: number; // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied dispatch: Function; folders: any[]; collapsedFolderIds: string[]; notesParentType: string; selectedFolderId: string; selectedTagId: string; selectedSmartFilterId: string; decryptionWorker: any; resourceFetcher: any; syncReport: any; tags: any[]; syncStarted: boolean; plugins: PluginStates; folderHeaderIsExpanded: boolean; tagHeaderIsExpanded: boolean; } const commands = [ require('./commands/focusElementSideBar'), ]; function ExpandIcon(props: any) { const theme = themeStyle(props.themeId); const style: any = { width: 16, maxWidth: 16, opacity: 0.5, fontSize: Math.round(theme.toolbarIconSize * 0.8), display: 'flex', justifyContent: 'center' }; if (!props.isVisible) style.visibility = 'hidden'; return ; } function ExpandLink(props: any) { return props.hasChildren ? ( ) : ( ); } const renderFolderIcon = (folderIcon: FolderIcon) => { if (!folderIcon) { const defaultFolderIcon: FolderIcon = { dataUrl: '', emoji: '', name: 'far fa-folder', type: FolderIconType.FontAwesome, }; return
; } return
; }; function FolderItem(props: any) { const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props; const noteCountComp = noteCount ? {noteCount} : null; const shareIcon = shareId && !parentId ? : null; return ( { folderItem_click(folderId); }} onDoubleClick={onFolderToggleClick_} > {showFolderIcon ? renderFolderIcon(folderIcon) : null}{folderTitle} {shareIcon} {noteCountComp} ); } const menuUtils = new MenuUtils(CommandService.instance()); const SidebarComponent = (props: Props) => { const folderItemsOrder_ = useRef(); folderItemsOrder_.current = []; const tagItemsOrder_ = useRef(); tagItemsOrder_.current = []; const rootRef = useRef(null); const anchorItemRefs = useRef>({}); // This whole component is a bit of a mess and rather than passing // a plugins prop around, not knowing how it's going to affect // re-rendering, we just keep a ref to it. Currently that's enough // as plugins are only accessed from context menus. However if want // to do more complex things with plugins in the sidebar, it will // probably have to be refactored using React Hooks first. const pluginsRef = useRef(null); pluginsRef.current = props.plugins; // 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 getSelectedItem = useCallback(() => { if (props.notesParentType === 'Folder' && props.selectedFolderId) { return { type: 'folder', id: props.selectedFolderId }; } else if (props.notesParentType === 'Tag' && props.selectedTagId) { return { type: 'tag', id: props.selectedTagId }; } return null; }, [props.notesParentType, props.selectedFolderId, props.selectedTagId]); const getFirstAnchorItemRef = useCallback((type: string) => { const refs = anchorItemRefs.current[type]; if (!refs) return null; const p = type === 'folder' ? props.folders : props.tags; const item = p && p.length ? p[0] : null; if (!item) return null; return refs[item.id]; }, [anchorItemRefs, props.folders, props.tags]); useEffect(() => { const runtimeProps: RuntimeProps = { getSelectedItem, anchorItemRefs, getFirstAnchorItemRef, }; CommandService.instance().componentRegisterCommands(runtimeProps, commands); return () => { CommandService.instance().componentUnregisterCommands(commands); }; }, [ getSelectedItem, anchorItemRefs, getFirstAnchorItemRef, ]); const onFolderDragStart_ = useCallback((event: any) => { const folderId = event.currentTarget.getAttribute('data-folder-id'); if (!folderId) return; event.dataTransfer.setDragImage(new Image(), 1, 1); event.dataTransfer.clearData(); event.dataTransfer.setData('text/x-jop-folder-ids', JSON.stringify([folderId])); }, []); const onFolderDragOver_ = useCallback((event: any) => { 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_ = useCallback(async (event: any) => { 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')); for (let i = 0; i < noteIds.length; i++) { await Note.moveToFolder(noteIds[i], 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')); for (let i = 0; i < folderIds.length; i++) { await Folder.moveToFolder(folderIds[i], folderId); } } } catch (error) { logger.error(error); alert(error.message); } }, []); const onTagDrop_ = useCallback(async (event: any) => { 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 onFolderToggleClick_ = useCallback((event: any) => { const folderId = event.currentTarget.getAttribute('data-folder-id'); props.dispatch({ type: 'FOLDER_TOGGLE', id: folderId, }); }, [props.dispatch]); const header_contextMenu = useCallback(async () => { const menu = new Menu(); menu.append( new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder')), ); menu.popup({ window: bridge().window() }); }, []); const itemContextMenu = useCallback(async (event: any) => { 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'); const state: AppState = store().getState(); let deleteMessage = ''; const deleteButtonLabel = _('Remove'); if (itemType === BaseModel.TYPE_TAG) { const tag = await Tag.load(itemId); deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32)); } else if (itemType === BaseModel.TYPE_SEARCH) { deleteMessage = _('Remove this search from the sidebar?'); } const menu = new Menu(); let item = null; if (itemType === BaseModel.TYPE_FOLDER) { item = BaseModel.byId(props.folders, itemId); } if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) { menu.append( new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)), ); } if (itemType === BaseModel.TYPE_FOLDER) { menu.append( new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId)), ); } 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) { await Tag.untagAll(itemId); } else if (itemType === BaseModel.TYPE_SEARCH) { props.dispatch({ type: 'SEARCH_DELETE', id: itemId, }); } }, }), ); } if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) { menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: 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: [itemId], plugins: pluginsRef.current }); }, }), ); } // We don't display the "Share notebook" menu item for sub-notebooks // that are within a shared notebook. If user wants to do this, // they'd have to move the notebook out of the shared notebook // first. const whenClause = stateToWhenClauseContext(state, { commandFolderId: itemId }); if (CommandService.instance().isEnabled('showShareFolderDialog', whenClause)) { menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId))); } if (CommandService.instance().isEnabled('leaveSharedFolder', whenClause)) { menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('leaveSharedFolder', itemId))); } menu.append( new MenuItem({ label: _('Export'), submenu: exportMenu, }), ); if (Setting.value('notes.perFolderSortOrderEnabled')) { menu.append(new MenuItem({ ...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId), type: 'checkbox', checked: PerFolderSortOrderService.isSet(itemId), })); } } if (itemType === BaseModel.TYPE_FOLDER) { menu.append( new MenuItem({ label: _('Copy external link'), click: () => { clipboard.writeText(getFolderCallbackUrl(itemId)); }, }), ); } if (itemType === BaseModel.TYPE_TAG) { 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 || itemType === ModelType.Folder && location === MenuItemLocation.FolderContextMenu ) { menu.append( new MenuItem(menuUtils.commandToStatefulMenuItem(view.commandName, itemId)), ); } } menu.popup({ window: bridge().window() }); }, [props.folders, props.dispatch, pluginsRef]); const folderItem_click = useCallback((folderId: string) => { props.dispatch({ type: 'FOLDER_SELECT', id: folderId ? folderId : null, }); }, [props.dispatch]); const tagItem_click = useCallback((tag: any) => { props.dispatch({ type: 'TAG_SELECT', id: tag ? tag.id : null, }); }, [props.dispatch]); const onHeaderClick_ = useCallback((key: string) => { const isExpanded = key === 'tagHeader' ? props.tagHeaderIsExpanded : props.folderHeaderIsExpanded; Setting.setValue(key === 'tagHeader' ? 'tagHeaderIsExpanded' : 'folderHeaderIsExpanded', !isExpanded); }, [props.folderHeaderIsExpanded, props.tagHeaderIsExpanded]); const onAllNotesClick_ = () => { props.dispatch({ type: 'SMART_FILTER_SELECT', id: ALL_NOTES_FILTER_ID, }); }; const anchorItemRef = (type: string, id: string) => { if (!anchorItemRefs.current[type]) anchorItemRefs.current[type] = {}; if (anchorItemRefs.current[type][id]) return anchorItemRefs.current[type][id]; anchorItemRefs.current[type][id] = React.createRef(); return anchorItemRefs.current[type][id]; }; const renderNoteCount = (count: number) => { return count ? {count} : null; }; const renderExpandIcon = (theme: any, isExpanded: boolean, isVisible: boolean) => { const style: any = { width: 16, maxWidth: 16, opacity: 0.5, fontSize: Math.round(theme.toolbarIconSize * 0.8), display: 'flex', justifyContent: 'center' }; if (!isVisible) style.visibility = 'hidden'; return ; }; const renderAllNotesItem = (theme: Theme, selected: boolean) => { return ( {renderExpandIcon(theme, false, false)} {_('All notes')} ); }; const renderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) =>{ const anchorRef = anchorItemRef('folder', folder.id); const isExpanded = props.collapsedFolderIds.indexOf(folder.id) < 0; let noteCount = (folder as any).note_count; // 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].note_count; } } } return ; }; const renderTag = (tag: any, selected: boolean) => { const anchorRef = anchorItemRef('tag', tag.id); let noteCount = null; if (Setting.value('showNoteCounts')) { if (Setting.value('showCompletedTodos')) noteCount = renderNoteCount(tag.note_count); else noteCount = renderNoteCount(tag.note_count - tag.todo_completed_count); } return ( {renderExpandIcon(theme, false, false)} { tagItem_click(tag); }} > {Tag.displayTitle(tag)} {noteCount} ); }; // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied const renderHeader = (key: string, label: string, iconName: string, contextMenuHandler: Function = null, onPlusButtonClick: Function = null, extraProps: any = {}) => { const headerClick = extraProps.onClick || null; delete extraProps.onClick; const ref = anchorItemRef('headers', key); return (
{ // if a custom click event is attached, trigger that. if (headerClick) { headerClick(key, event); } onHeaderClick_(key); }} > {label} { onPlusButtonClick && }
); }; const onKeyDown = useCallback((event: any) => { const keyCode = event.keyCode; const selectedItem = getSelectedItem(); if (keyCode === 40 || keyCode === 38) { // DOWN / UP event.preventDefault(); const focusItems = []; for (let i = 0; i < folderItemsOrder_.current.length; i++) { const id = folderItemsOrder_.current[i]; focusItems.push({ id: id, ref: anchorItemRefs.current['folder'][id], type: 'folder' }); } for (let i = 0; i < tagItemsOrder_.current.length; i++) { const id = tagItemsOrder_.current[i]; focusItems.push({ id: id, ref: anchorItemRefs.current['tag'][id], type: 'tag' }); } let currentIndex = 0; for (let i = 0; i < focusItems.length; i++) { if (!selectedItem || focusItems[i].id === selectedItem.id) { currentIndex = i; break; } } const inc = keyCode === 38 ? -1 : +1; let newIndex = currentIndex + inc; if (newIndex < 0) newIndex = 0; if (newIndex > focusItems.length - 1) newIndex = focusItems.length - 1; const focusItem = focusItems[newIndex]; const actionName = `${focusItem.type.toUpperCase()}_SELECT`; props.dispatch({ type: actionName, id: focusItem.id, }); focusItem.ref.current.focus(); } if (keyCode === 9) { // TAB event.preventDefault(); if (event.shiftKey) { void CommandService.instance().execute('focusElement', 'noteBody'); } else { void CommandService.instance().execute('focusElement', 'noteList'); } } if (selectedItem && selectedItem.type === 'folder' && keyCode === 32) { // SPACE event.preventDefault(); props.dispatch({ type: 'FOLDER_TOGGLE', id: selectedItem.id, }); } if (keyCode === 65 && (event.ctrlKey || event.metaKey)) { // Ctrl+A key event.preventDefault(); } }, [getSelectedItem, props.dispatch]); const renderSynchronizeButton = (type: string) => { const label = type === 'sync' ? _('Synchronise') : _('Cancel'); const iconAnimation = type !== 'sync' ? 'icon-infinite-rotation 1s linear infinite' : ''; return ( { void CommandService.instance().execute('synchronize', type !== 'sync'); }} /> ); }; const onAddFolderButtonClick = useCallback(() => { void CommandService.instance().execute('newFolder'); }, []); const theme = themeStyle(props.themeId); const items = []; items.push( renderHeader('folderHeader', _('Notebooks'), 'icon-notebooks', header_contextMenu, onAddFolderButtonClick, { onDrop: onFolderDrop_, ['data-folder-id']: '', toggleblock: 1, }), ); const foldersStyle = useMemo(() => { return { display: props.folderHeaderIsExpanded ? 'block' : 'none', paddingBottom: 10 }; }, [props.folderHeaderIsExpanded]); if (props.folders.length) { const allNotesSelected = props.notesParentType === 'SmartFilter' && props.selectedSmartFilterId === ALL_NOTES_FILTER_ID; const result = shared.renderFolders(props, renderFolderItem); const folderItems = [renderAllNotesItem(theme, allNotesSelected)].concat(result.items); folderItemsOrder_.current = result.order; items.push(
{folderItems}
, ); } items.push( renderHeader('tagHeader', _('Tags'), 'icon-tags', null, null, { toggleblock: 1, }), ); if (props.tags.length) { const result = shared.renderTags(props, renderTag); const tagItems = result.items; tagItemsOrder_.current = result.order; items.push(
{tagItems}
, ); } let decryptionReportText = ''; if (props.decryptionWorker && props.decryptionWorker.state !== 'idle' && props.decryptionWorker.itemCount) { decryptionReportText = _('Decrypting items: %d/%d', props.decryptionWorker.itemIndex + 1, props.decryptionWorker.itemCount); } let resourceFetcherText = ''; if (props.resourceFetcher && props.resourceFetcher.toFetchCount) { resourceFetcherText = _('Fetching resources: %d/%d', props.resourceFetcher.fetchingCount, props.resourceFetcher.toFetchCount); } const lines = Synchronizer.reportToLines(props.syncReport); if (resourceFetcherText) lines.push(resourceFetcherText); if (decryptionReportText) lines.push(decryptionReportText); const syncReportText = []; for (let i = 0; i < lines.length; i++) { syncReportText.push( {lines[i]} , ); } const syncButton = renderSynchronizeButton(props.syncStarted ? 'cancel' : 'sync'); const syncReportComp = !syncReportText.length ? null : ( {syncReportText} ); return (
{items}
{syncReportComp} {syncButton}
); }; const mapStateToProps = (state: AppState) => { return { folders: state.folders, tags: state.tags, searches: state.searches, syncStarted: state.syncStarted, syncReport: state.syncReport, selectedFolderId: state.selectedFolderId, selectedTagId: state.selectedTagId, selectedSearchId: state.selectedSearchId, selectedSmartFilterId: state.selectedSmartFilterId, notesParentType: state.notesParentType, locale: state.settings.locale, themeId: state.settings.theme, collapsedFolderIds: state.collapsedFolderIds, decryptionWorker: state.decryptionWorker, resourceFetcher: state.resourceFetcher, plugins: state.pluginService.plugins, tagHeaderIsExpanded: state.settings.tagHeaderIsExpanded, folderHeaderIsExpanded: state.settings.folderHeaderIsExpanded, }; }; export default connect(mapStateToProps)(SidebarComponent);