import * as React from 'react'; import { StyledRoot, StyledAddButton, 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'; const { connect } = require('react-redux'); const shared = require('@joplin/lib/components/shared/side-menu-shared.js'); const BaseModel = require('@joplin/lib/BaseModel').default; const Folder = require('@joplin/lib/models/Folder.js'); const Note = require('@joplin/lib/models/Note.js'); const Tag = require('@joplin/lib/models/Tag.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'); interface Props { themeId: number, dispatch: Function, folders: any[], collapsedFolderIds: string[], notesParentType: string, selectedFolderId: string, selectedTagId: string, selectedSmartFilterId:string, decryptionWorker: any, resourceFetcher: any, syncReport: any, tags: any[], syncStarted: boolean, } interface State { tagHeaderIsExpanded: boolean, folderHeaderIsExpanded: 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 ? ( ) : ( ); } function FolderItem(props:any) { const { hasChildren, isExpanded, depth, selected, folderId, folderTitle, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_ } = props; const noteCountComp = noteCount ? {noteCount} : null; return ( { folderItem_click(folderId); }} onDoubleClick={onFolderToggleClick_} > {folderTitle} {noteCountComp} ); } const menuUtils = new MenuUtils(CommandService.instance()); class SideBarComponent extends React.Component { private folderItemsOrder_:any[] = []; private tagItemsOrder_:any[] = []; private rootRef:any = null; private anchorItemRefs:any = {}; constructor(props:any) { super(props); CommandService.instance().componentRegisterCommands(this, commands); this.state = { tagHeaderIsExpanded: Setting.value('tagHeaderIsExpanded'), folderHeaderIsExpanded: Setting.value('folderHeaderIsExpanded'), }; this.onFolderToggleClick_ = this.onFolderToggleClick_.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.onAllNotesClick_ = this.onAllNotesClick_.bind(this); this.header_contextMenu = this.header_contextMenu.bind(this); this.onAddFolderButtonClick = this.onAddFolderButtonClick.bind(this); this.folderItem_click = this.folderItem_click.bind(this); this.itemContextMenu = this.itemContextMenu.bind(this); } onFolderDragStart_(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])); } onFolderDragOver_(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(); } async onFolderDrop_(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. 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); } } } async onTagDrop_(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]); } } } async onFolderToggleClick_(event:any) { const folderId = event.currentTarget.getAttribute('data-folder-id'); this.props.dispatch({ type: 'FOLDER_TOGGLE', id: folderId, }); } componentWillUnmount() { CommandService.instance().componentUnregisterCommands(commands); } async header_contextMenu() { const menu = new Menu(); menu.append( new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder')) ); menu.popup(bridge().window()); } async itemContextMenu(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'); let deleteMessage = ''; let buttonLabel = _('Remove'); if (itemType === BaseModel.TYPE_FOLDER) { const folder = await Folder.load(itemId); deleteMessage = _('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', substrWithEllipsis(folder.title, 0, 32)); buttonLabel = _('Delete'); } else 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(this.props.folders, itemId); } if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) { menu.append( new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)) ); } menu.append( new MenuItem({ label: buttonLabel, click: async () => { const ok = bridge().showConfirmMessageBox(deleteMessage, { buttons: [buttonLabel, _('Cancel')], defaultId: 1, }); if (!ok) return; if (itemType === BaseModel.TYPE_FOLDER) { await Folder.delete(itemId); } else if (itemType === BaseModel.TYPE_TAG) { await Tag.untagAll(itemId); } else if (itemType === BaseModel.TYPE_SEARCH) { this.props.dispatch({ type: 'SEARCH_DELETE', id: itemId, }); } }, }) ); if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) { menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('renameFolder', 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(this.props.dispatch.bind(this), module, { sourceFolderIds: [itemId] }); }, }) ); } menu.append( new MenuItem({ label: _('Export'), submenu: exportMenu, }) ); } if (itemType === BaseModel.TYPE_TAG) { menu.append(new MenuItem( menuUtils.commandToStatefulMenuItem('renameTag', itemId) )); } menu.popup(bridge().window()); } folderItem_click(folderId:string) { this.props.dispatch({ type: 'FOLDER_SELECT', id: folderId ? folderId : null, }); } tagItem_click(tag:any) { this.props.dispatch({ type: 'TAG_SELECT', id: tag ? tag.id : null, }); } anchorItemRef(type:string, id:string) { if (!this.anchorItemRefs[type]) this.anchorItemRefs[type] = {}; if (this.anchorItemRefs[type][id]) return this.anchorItemRefs[type][id]; this.anchorItemRefs[type][id] = React.createRef(); return this.anchorItemRefs[type][id]; } firstAnchorItemRef(type:string) { const refs = this.anchorItemRefs[type]; if (!refs) return null; const n = `${type}s`; const p = this.props as any; const item = p[n] && p[n].length ? p[n][0] : null; if (!item) return null; return refs[item.id]; } renderNoteCount(count:number) { return count ? {count} : null; } renderExpandIcon(isExpanded:boolean, isVisible:boolean = true) { const theme = themeStyle(this.props.themeId); 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 ; } renderAllNotesItem(selected:boolean) { return ( {this.renderExpandIcon(false, false)} {_('All notes')} ); } renderFolderItem(folder:any, selected:boolean, hasChildren:boolean, depth:number) { const anchorRef = this.anchorItemRef('folder', folder.id); const isExpanded = this.props.collapsedFolderIds.indexOf(folder.id) < 0; let noteCount = folder.note_count; // Thunderbird count: Subtract children note_count from parent folder if it expanded. if (isExpanded) { for (let i = 0; i < this.props.folders.length; i++) { if (this.props.folders[i].parent_id === folder.id) { noteCount -= this.props.folders[i].note_count; } } } return ; } renderTag(tag:any, selected:boolean) { const anchorRef = this.anchorItemRef('tag', tag.id); const noteCount = Setting.value('showNoteCounts') ? this.renderNoteCount(tag.note_count) : ''; return ( {this.renderExpandIcon(false, false)} { this.tagItem_click(tag); }} > {Tag.displayTitle(tag)} {noteCount} ); } makeDivider(key:string) { return
; } 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 = this.anchorItemRef('headers', key); return (
{ // if a custom click event is attached, trigger that. if (headerClick) { headerClick(key, event); } this.onHeaderClick_(key); }} > {label} { onPlusButtonClick && }
); } selectedItem() { if (this.props.notesParentType === 'Folder' && this.props.selectedFolderId) { return { type: 'folder', id: this.props.selectedFolderId }; } else if (this.props.notesParentType === 'Tag' && this.props.selectedTagId) { return { type: 'tag', id: this.props.selectedTagId }; } return null; } onKeyDown(event:any) { const keyCode = event.keyCode; const selectedItem = this.selectedItem(); if (keyCode === 40 || keyCode === 38) { // DOWN / UP event.preventDefault(); const focusItems = []; for (let i = 0; i < this.folderItemsOrder_.length; i++) { const id = this.folderItemsOrder_[i]; focusItems.push({ id: id, ref: this.anchorItemRefs['folder'][id], type: 'folder' }); } for (let i = 0; i < this.tagItemsOrder_.length; i++) { const id = this.tagItemsOrder_[i]; focusItems.push({ id: id, ref: this.anchorItemRefs['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`; this.props.dispatch({ type: actionName, id: focusItem.id, }); focusItem.ref.current.focus(); } if (keyCode === 9) { // TAB event.preventDefault(); if (event.shiftKey) { CommandService.instance().execute('focusElement', 'noteBody'); } else { CommandService.instance().execute('focusElement', 'noteList'); } } if (selectedItem && selectedItem.type === 'folder' && keyCode === 32) { // SPACE event.preventDefault(); this.props.dispatch({ type: 'FOLDER_TOGGLE', id: selectedItem.id, }); } if (keyCode === 65 && (event.ctrlKey || event.metaKey)) { // Ctrl+A key event.preventDefault(); } } onHeaderClick_(key:string) { const toggleKey = `${key}IsExpanded`; const isExpanded = (this.state as any)[toggleKey]; const newState:any = { [toggleKey]: !isExpanded }; this.setState(newState); Setting.setValue(toggleKey, !isExpanded); } onAllNotesClick_() { this.props.dispatch({ type: 'SMART_FILTER_SELECT', id: ALL_NOTES_FILTER_ID, }); } renderSynchronizeButton(type:string) { const label = type === 'sync' ? _('Synchronise') : _('Cancel'); const iconAnimation = type !== 'sync' ? 'icon-infinite-rotation 1s linear infinite' : ''; return ( { CommandService.instance().execute('synchronize', type !== 'sync'); }} /> ); } onAddFolderButtonClick() { CommandService.instance().execute('newFolder'); } // componentDidUpdate(prevProps:any, prevState:any) { // for (const n in prevProps) { // if (prevProps[n] !== (this.props as any)[n]) { // console.info('CHANGED PROPS', n); // } // } // for (const n in prevState) { // if (prevState[n] !== (this.state as any)[n]) { // console.info('CHANGED STATE', n); // } // } // } render() { const theme = themeStyle(this.props.themeId); const items = []; items.push( this.renderHeader('folderHeader', _('Notebooks'), 'icon-notebooks', this.header_contextMenu, this.onAddFolderButtonClick, { onDrop: this.onFolderDrop_, ['data-folder-id']: '', toggleblock: 1, }) ); if (this.props.folders.length) { const allNotesSelected = this.props.notesParentType === 'SmartFilter' && this.props.selectedSmartFilterId === ALL_NOTES_FILTER_ID; const result = shared.renderFolders(this.props, this.renderFolderItem.bind(this)); const folderItems = [this.renderAllNotesItem(allNotesSelected)].concat(result.items); this.folderItemsOrder_ = result.order; items.push(
{folderItems}
); } items.push( this.renderHeader('tagHeader', _('Tags'), 'icon-tags', null, null, { toggleblock: 1, }) ); if (this.props.tags.length) { const result = shared.renderTags(this.props, this.renderTag.bind(this)); const tagItems = result.items; this.tagItemsOrder_ = result.order; items.push(
{tagItems}
); } let decryptionReportText = ''; if (this.props.decryptionWorker && this.props.decryptionWorker.state !== 'idle' && this.props.decryptionWorker.itemCount) { decryptionReportText = _('Decrypting items: %d/%d', this.props.decryptionWorker.itemIndex + 1, this.props.decryptionWorker.itemCount); } let resourceFetcherText = ''; if (this.props.resourceFetcher && this.props.resourceFetcher.toFetchCount) { resourceFetcherText = _('Fetching resources: %d/%d', this.props.resourceFetcher.fetchingCount, this.props.resourceFetcher.toFetchCount); } const lines = Synchronizer.reportToLines(this.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 = this.renderSynchronizeButton(this.props.syncStarted ? 'cancel' : 'sync'); const syncReportComp = !syncReportText.length ? null : ( {syncReportText} ); return (
{items}
{syncReportComp} {syncButton}
); } } const mapStateToProps = (state:any) => { 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, sidebarVisibility: state.sidebarVisibility, noteListVisibility: state.noteListVisibility, }; }; export default connect(mapStateToProps)(SideBarComponent);