1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-07-16 00:14:34 +02:00

Desktop: Resolves #4251: Refactor sidebar to better handle thousands of tags and notebooks (#10331)

This commit is contained in:
Henry Heino
2024-04-25 07:31:18 -07:00
committed by GitHub
parent c6c7de286a
commit 97b5276f81
40 changed files with 1688 additions and 915 deletions

View File

@ -396,10 +396,26 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js
packages/app-desktop/gui/SearchBar/SearchBar.js packages/app-desktop/gui/SearchBar/SearchBar.js
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js
packages/app-desktop/gui/ShareNoteDialog.js packages/app-desktop/gui/ShareNoteDialog.js
packages/app-desktop/gui/Sidebar/FolderAndTagList.js
packages/app-desktop/gui/Sidebar/Sidebar.js packages/app-desktop/gui/Sidebar/Sidebar.js
packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.js packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.js
packages/app-desktop/gui/Sidebar/commands/index.js packages/app-desktop/gui/Sidebar/commands/index.js
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.js
packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/NoteCount.js
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
packages/app-desktop/gui/Sidebar/styles/index.js packages/app-desktop/gui/Sidebar/styles/index.js
packages/app-desktop/gui/Sidebar/types.js
packages/app-desktop/gui/StatusScreen/StatusScreen.js packages/app-desktop/gui/StatusScreen/StatusScreen.js
packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js
packages/app-desktop/gui/SyncWizard/Dialog.js packages/app-desktop/gui/SyncWizard/Dialog.js
@ -414,6 +430,7 @@ packages/app-desktop/gui/ToolbarSpace.js
packages/app-desktop/gui/TrashNotification/TrashNotification.js packages/app-desktop/gui/TrashNotification/TrashNotification.js
packages/app-desktop/gui/dialogs.js packages/app-desktop/gui/dialogs.js
packages/app-desktop/gui/hooks/useEffectDebugger.js packages/app-desktop/gui/hooks/useEffectDebugger.js
packages/app-desktop/gui/hooks/useElementHeight.js
packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js
packages/app-desktop/gui/hooks/usePrevious.js packages/app-desktop/gui/hooks/usePrevious.js
packages/app-desktop/gui/hooks/usePropsDebugger.js packages/app-desktop/gui/hooks/usePropsDebugger.js
@ -434,6 +451,8 @@ packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/models/MainScreen.js packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js packages/app-desktop/integration-tests/models/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/SettingsScreen.js packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js
packages/app-desktop/integration-tests/sidebar.spec.js
packages/app-desktop/integration-tests/simpleBackup.spec.js packages/app-desktop/integration-tests/simpleBackup.spec.js
packages/app-desktop/integration-tests/util/activateMainMenuItem.js packages/app-desktop/integration-tests/util/activateMainMenuItem.js
packages/app-desktop/integration-tests/util/createStartupArgs.js packages/app-desktop/integration-tests/util/createStartupArgs.js

19
.gitignore vendored
View File

@ -376,10 +376,26 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js
packages/app-desktop/gui/SearchBar/SearchBar.js packages/app-desktop/gui/SearchBar/SearchBar.js
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js
packages/app-desktop/gui/ShareNoteDialog.js packages/app-desktop/gui/ShareNoteDialog.js
packages/app-desktop/gui/Sidebar/FolderAndTagList.js
packages/app-desktop/gui/Sidebar/Sidebar.js packages/app-desktop/gui/Sidebar/Sidebar.js
packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.js packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.js
packages/app-desktop/gui/Sidebar/commands/index.js packages/app-desktop/gui/Sidebar/commands/index.js
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.js
packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/NoteCount.js
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
packages/app-desktop/gui/Sidebar/styles/index.js packages/app-desktop/gui/Sidebar/styles/index.js
packages/app-desktop/gui/Sidebar/types.js
packages/app-desktop/gui/StatusScreen/StatusScreen.js packages/app-desktop/gui/StatusScreen/StatusScreen.js
packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js
packages/app-desktop/gui/SyncWizard/Dialog.js packages/app-desktop/gui/SyncWizard/Dialog.js
@ -394,6 +410,7 @@ packages/app-desktop/gui/ToolbarSpace.js
packages/app-desktop/gui/TrashNotification/TrashNotification.js packages/app-desktop/gui/TrashNotification/TrashNotification.js
packages/app-desktop/gui/dialogs.js packages/app-desktop/gui/dialogs.js
packages/app-desktop/gui/hooks/useEffectDebugger.js packages/app-desktop/gui/hooks/useEffectDebugger.js
packages/app-desktop/gui/hooks/useElementHeight.js
packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js
packages/app-desktop/gui/hooks/usePrevious.js packages/app-desktop/gui/hooks/usePrevious.js
packages/app-desktop/gui/hooks/usePropsDebugger.js packages/app-desktop/gui/hooks/usePropsDebugger.js
@ -414,6 +431,8 @@ packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/models/MainScreen.js packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js packages/app-desktop/integration-tests/models/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/SettingsScreen.js packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js
packages/app-desktop/integration-tests/sidebar.spec.js
packages/app-desktop/integration-tests/simpleBackup.spec.js packages/app-desktop/integration-tests/simpleBackup.spec.js
packages/app-desktop/integration-tests/util/activateMainMenuItem.js packages/app-desktop/integration-tests/util/activateMainMenuItem.js
packages/app-desktop/integration-tests/util/createStartupArgs.js packages/app-desktop/integration-tests/util/createStartupArgs.js

View File

@ -22,6 +22,7 @@ interface Props {
title?: string; title?: string;
iconName?: string; iconName?: string;
level?: ButtonLevel; level?: ButtonLevel;
iconLabel?: string;
className?: string; className?: string;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onClick?: Function; onClick?: Function;
@ -219,7 +220,7 @@ const Button = React.forwardRef((props: Props, ref: any) => {
function renderIcon() { function renderIcon() {
if (!props.iconName) return null; if (!props.iconName) return null;
return <StyledIcon animation={props.iconAnimation} mr={iconOnly ? '0' : '6px'} color={props.color} className={props.iconName}/>; return <StyledIcon aria-label={props.iconLabel} animation={props.iconAnimation} mr={iconOnly ? '0' : '6px'} color={props.color} className={props.iconName}/>;
} }
function renderTitle() { function renderTitle() {

View File

@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { useCallback, useState, useRef, useEffect } from 'react'; import { useCallback, useState, useRef, useEffect, useId } from 'react';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import DialogButtonRow, { ClickEvent } from '../DialogButtonRow'; import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
import Dialog from '../Dialog'; import Dialog from '../Dialog';
@ -127,13 +127,14 @@ export default function(props: Props) {
} }
}, []); }, []);
const formTitleInputId = useId();
function renderForm() { function renderForm() {
return ( return (
<div> <div>
<div className="form"> <div className="form">
<div className="form-input-group"> <div className="form-input-group">
<label>{_('Title')}</label> <label htmlFor={formTitleInputId}>{_('Title')}</label>
<StyledInput type="text" ref={titleInputRef} value={folderTitle} onChange={onFolderTitleChange}/> <StyledInput id={formTitleInputId} type="text" ref={titleInputRef} value={folderTitle} onChange={onFolderTitleChange}/>
</div> </div>
<div className="form-input-group"> <div className="form-input-group">

View File

@ -1,19 +1,15 @@
import * as React from 'react'; import * as React from 'react';
import { DragEventHandler, KeyboardEventHandler, UIEventHandler } from 'react';
interface Props { interface Props<ItemType> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied style: React.CSSProperties & { height: number };
style: any;
itemHeight: number; itemHeight: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied items: ItemType[];
items: any[];
disabled?: boolean; disabled?: boolean;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied onKeyDown?: KeyboardEventHandler<HTMLElement>;
onKeyDown?: Function; itemRenderer: (item: ItemType, index: number)=> React.JSX.Element;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
itemRenderer: Function;
className?: string; className?: string;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied onItemDrop?: DragEventHandler<HTMLElement>;
onNoteDrop?: Function;
} }
interface State { interface State {
@ -21,13 +17,12 @@ interface State {
bottomItemIndex: number; bottomItemIndex: number;
} }
class ItemList extends React.Component<Props, State> { class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
private scrollTop_: number; private scrollTop_: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private listRef: React.MutableRefObject<HTMLDivElement>;
private listRef: any;
public constructor(props: Props) { public constructor(props: Props<ItemType>) {
super(props); super(props);
this.scrollTop_ = 0; this.scrollTop_ = 0;
@ -39,12 +34,12 @@ class ItemList extends React.Component<Props, State> {
this.onDrop = this.onDrop.bind(this); this.onDrop = this.onDrop.bind(this);
} }
public visibleItemCount(props: Props = undefined) { public visibleItemCount(props: Props<ItemType> = undefined) {
if (typeof props === 'undefined') props = this.props; if (typeof props === 'undefined') props = this.props;
return Math.ceil(props.style.height / props.itemHeight); return Math.ceil(props.style.height / props.itemHeight);
} }
public updateStateItemIndexes(props: Props = undefined) { public updateStateItemIndexes(props: Props<ItemType> = undefined) {
if (typeof props === 'undefined') props = this.props; if (typeof props === 'undefined') props = this.props;
const topItemIndex = Math.floor(this.scrollTop_ / props.itemHeight); const topItemIndex = Math.floor(this.scrollTop_ / props.itemHeight);
@ -67,35 +62,47 @@ class ItemList extends React.Component<Props, State> {
return this.scrollTop_; return this.scrollTop_;
} }
public get container() {
return this.listRef.current;
}
public UNSAFE_componentWillMount() { public UNSAFE_componentWillMount() {
this.updateStateItemIndexes(); this.updateStateItemIndexes();
} }
public UNSAFE_componentWillReceiveProps(newProps: Props) { public UNSAFE_componentWillReceiveProps(newProps: Props<ItemType>) {
this.updateStateItemIndexes(newProps); this.updateStateItemIndexes(newProps);
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public onScroll: UIEventHandler<HTMLDivElement> = event => {
public onScroll(event: any) { this.scrollTop_ = (event.target as HTMLElement).scrollTop;
this.scrollTop_ = event.target.scrollTop;
this.updateStateItemIndexes(); this.updateStateItemIndexes();
} };
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public onKeyDown: KeyboardEventHandler<HTMLElement> = event => {
public onKeyDown(event: any) {
if (this.props.onKeyDown) this.props.onKeyDown(event); if (this.props.onKeyDown) this.props.onKeyDown(event);
};
public onDrop: DragEventHandler<HTMLElement> = event => {
if (this.props.onItemDrop) this.props.onItemDrop(event);
};
public get firstVisibleIndex() {
return Math.min(this.props.items.length - 1, this.state.topItemIndex);
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public get lastVisibleIndex() {
public onDrop(event: any) { return Math.max(0, this.state.bottomItemIndex);
if (this.props.onNoteDrop) this.props.onNoteDrop(event); }
public isIndexVisible(itemIndex: number) {
return itemIndex >= this.firstVisibleIndex && itemIndex <= this.lastVisibleIndex;
} }
public makeItemIndexVisible(itemIndex: number) { public makeItemIndexVisible(itemIndex: number) {
const top = Math.min(this.props.items.length - 1, this.state.topItemIndex); if (this.isIndexVisible(itemIndex)) return;
const bottom = Math.max(0, this.state.bottomItemIndex);
if (itemIndex >= top && itemIndex <= bottom) return; const top = this.firstVisibleIndex;
let scrollTop = 0; let scrollTop = 0;
if (itemIndex < top) { if (itemIndex < top) {
@ -130,8 +137,11 @@ class ItemList extends React.Component<Props, State> {
public render() { public render() {
const items = this.props.items; const items = this.props.items;
const style = { ...this.props.style, overflowX: 'hidden', const style: React.CSSProperties = {
overflowY: 'auto' }; ...this.props.style,
overflowX: 'hidden',
overflowY: 'auto',
};
// if (this.props.disabled) style.opacity = 0.5; // if (this.props.disabled) style.opacity = 0.5;

View File

@ -0,0 +1,100 @@
import * as React from 'react';
import { AppState } from '../../app.reducer';
import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import { useMemo, useRef, useState } from 'react';
import ItemList from '../ItemList';
import useElementHeight from '../hooks/useElementHeight';
import useSidebarListData from './hooks/useSidebarListData';
import useSelectedSidebarIndex from './hooks/useSelectedSidebarIndex';
import useOnSidebarKeyDownHandler from './hooks/useOnSidebarKeyDownHandler';
import useFocusHandler from './hooks/useFocusHandler';
import useOnRenderItem from './hooks/useOnRenderItem';
import { ListItem } from './types';
import useSidebarCommandHandler from './hooks/useSidebarCommandHandler';
interface Props {
dispatch: Dispatch;
themeId: number;
plugins: PluginStates;
tags: TagsWithNoteCountEntity[];
folders: FolderEntity[];
notesParentType: string;
selectedTagId: string;
selectedFolderId: string;
selectedSmartFilterId: string;
collapsedFolderIds: string[];
folderHeaderIsExpanded: boolean;
tagHeaderIsExpanded: boolean;
}
const FolderAndTagList: React.FC<Props> = props => {
const listItems = useSidebarListData(props);
const { selectedIndex, updateSelectedIndex } = useSelectedSidebarIndex({
...props,
listItems: listItems,
});
const [selectedListElement, setSelectedListElement] = useState<HTMLElement|null>(null);
const onRenderItem = useOnRenderItem({
...props,
selectedIndex,
onSelectedElementShown: setSelectedListElement,
});
const onKeyEventHandler = useOnSidebarKeyDownHandler({
dispatch: props.dispatch,
listItems: listItems,
selectedIndex,
updateSelectedIndex,
});
const itemListRef = useRef<ItemList<ListItem>>();
const { focusSidebar } = useFocusHandler({ itemListRef, selectedListElement, selectedIndex, listItems });
useSidebarCommandHandler({ focusSidebar });
const [itemListContainer, setItemListContainer] = useState<HTMLDivElement|null>(null);
const listHeight = useElementHeight(itemListContainer);
const listStyle = useMemo(() => ({ height: listHeight }), [listHeight]);
return (
<div
className='folder-and-tag-list'
ref={setItemListContainer}
>
<ItemList
className='items'
ref={itemListRef}
style={listStyle}
items={listItems}
itemRenderer={onRenderItem}
onKeyDown={onKeyEventHandler}
itemHeight={30}
/>
</div>
);
};
const mapStateToProps = (state: AppState) => {
return {
themeId: state.settings.theme,
tags: state.tags,
folders: state.folders,
notesParentType: state.notesParentType,
selectedFolderId: state.selectedFolderId,
selectedTagId: state.selectedTagId,
collapsedFolderIds: state.collapsedFolderIds,
selectedSmartFilterId: state.selectedSmartFilterId,
plugins: state.pluginService.plugins,
tagHeaderIsExpanded: state.settings.tagHeaderIsExpanded,
folderHeaderIsExpanded: state.settings.folderHeaderIsExpanded,
};
};
export default connect(mapStateToProps)(FolderAndTagList);

View File

@ -1,765 +1,28 @@
import * as React from 'react'; import * as React from 'react';
import { useEffect, useRef, useCallback, useMemo, DragEventHandler, MouseEventHandler, RefObject } from 'react'; import { StyledRoot, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton } from './styles';
import { StyledRoot, StyledAddButton, StyledShareIcon, StyledHeader, StyledHeaderIcon, StyledAllNotesIcon, StyledHeaderLabel, StyledListItem, StyledListItemAnchor, StyledExpandLink, StyledNoteCount, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton, StyledSpanFix } from './styles';
import { ButtonLevel } from '../Button/Button'; import { ButtonLevel } from '../Button/Button';
import CommandService from '@joplin/lib/services/CommandService'; import CommandService from '@joplin/lib/services/CommandService';
import InteropService from '@joplin/lib/services/interop/InteropService';
import Synchronizer from '@joplin/lib/Synchronizer'; 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 { _ } 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 { AppState } from '../../app.reducer';
import { ModelType } from '@joplin/lib/BaseModel'; import { StateDecryptionWorker, StateResourceFetcher } from '@joplin/lib/reducer';
import BaseModel from '@joplin/lib/BaseModel';
import Folder from '@joplin/lib/models/Folder';
import Tag from '@joplin/lib/models/Tag';
import Logger from '@joplin/utils/Logger';
import { FolderEntity, FolderIcon, FolderIconType, TagEntity } from '@joplin/lib/services/database/types';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
import { StateDecryptionWorker, StateResourceFetcher, store } from '@joplin/lib/reducer';
import PerFolderSortOrderService from '../../services/sortOrder/PerFolderSortOrderService';
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import FolderIconBox from '../FolderIconBox';
import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop';
import { RuntimeProps } from './commands/focusElementSideBar';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared'; import { themeStyle } from '@joplin/lib/theme';
import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash';
import { focus } from '@joplin/lib/utils/focusHandler';
import { ThemeStyle, themeStyle } from '@joplin/lib/theme';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import bridge from '../../services/bridge'; import FolderAndTagList from './FolderAndTagList';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
import { substrWithEllipsis } from '@joplin/lib/string-utils';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
import { clipboard } from 'electron';
const logger = Logger.create('Sidebar');
interface Props { interface Props {
themeId: number; themeId: number;
dispatch: Dispatch; dispatch: Dispatch;
folders: FolderEntity[];
collapsedFolderIds: string[];
notesParentType: string;
selectedFolderId: string;
selectedTagId: string;
selectedSmartFilterId: string;
decryptionWorker: StateDecryptionWorker; decryptionWorker: StateDecryptionWorker;
resourceFetcher: StateResourceFetcher; resourceFetcher: StateResourceFetcher;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
syncReport: any; syncReport: any;
tags: TagEntity[];
syncStarted: boolean; syncStarted: boolean;
plugins: PluginStates;
folderHeaderIsExpanded: boolean;
tagHeaderIsExpanded: boolean;
} }
const commands = [
require('./commands/focusElementSideBar'),
];
interface ExpandIconProps {
themeId: number;
isExpanded: boolean;
isVisible: boolean;
}
function ExpandIcon(props: ExpandIconProps) {
const theme = themeStyle(props.themeId);
const style: React.CSSProperties = {
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 <i className={props.isExpanded ? 'fas fa-caret-down' : 'fas fa-caret-right'} style={style}></i>;
}
interface ExpandLinkProps {
themeId: number;
folderId: string;
hasChildren: boolean;
isExpanded: boolean;
onClick: MouseEventHandler<HTMLElement>;
}
function ExpandLink(props: ExpandLinkProps) {
return props.hasChildren ? (
<StyledExpandLink href="#" data-folder-id={props.folderId} onClick={props.onClick}>
<ExpandIcon themeId={props.themeId} isVisible={true} isExpanded={props.isExpanded}/>
</StyledExpandLink>
) : (
<StyledExpandLink><ExpandIcon themeId={props.themeId} isVisible={false} isExpanded={false}/></StyledExpandLink>
);
}
const renderFolderIcon = (folderIcon: FolderIcon) => {
if (!folderIcon) {
const defaultFolderIcon: FolderIcon = {
dataUrl: '',
emoji: '',
name: 'far fa-folder',
type: FolderIconType.FontAwesome,
};
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox opacity={0.7} folderIcon={defaultFolderIcon}/></div>;
}
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox folderIcon={folderIcon}/></div>;
};
type ItemDragListener = DragEventHandler<HTMLElement>;
type ItemContextMenuListener = MouseEventHandler<HTMLElement>;
type ItemClickListener = MouseEventHandler<HTMLElement>;
interface FolderItemProps {
themeId: number;
hasChildren: boolean;
showFolderIcon: boolean;
isExpanded: boolean;
parentId: string;
depth: number;
selected: boolean;
folderId: string;
folderTitle: string;
folderIcon: FolderIcon;
anchorRef: RefObject<HTMLElement>;
noteCount: number;
onFolderDragStart_: ItemDragListener;
onFolderDragOver_: ItemDragListener;
onFolderDrop_: ItemDragListener;
itemContextMenu: ItemContextMenuListener;
folderItem_click: (folderId: string)=> void;
onFolderToggleClick_: ItemClickListener;
shareId: string;
}
function FolderItem(props: FolderItemProps) {
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
const noteCountComp = noteCount ? <StyledNoteCount className="note-count-label">{noteCount}</StyledNoteCount> : null;
const shareIcon = shareId && !parentId ? <StyledShareIcon className="fas fa-share-alt"></StyledShareIcon> : null;
const draggable = ![getTrashFolderId(), Folder.conflictFolderId()].includes(folderId);
const doRenderFolderIcon = () => {
if (folderId === getTrashFolderId()) {
return renderFolderIcon(getTrashFolderIcon(FolderIconType.FontAwesome));
}
if (!showFolderIcon) return null;
return renderFolderIcon(folderIcon);
};
return (
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={draggable} data-folder-id={folderId}>
<ExpandLink themeId={props.themeId} hasChildren={hasChildren} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
<StyledListItemAnchor
ref={anchorRef}
className="list-item"
isConflictFolder={folderId === Folder.conflictFolderId()}
href="#"
selected={selected}
shareId={shareId}
data-id={folderId}
data-type={BaseModel.TYPE_FOLDER}
onContextMenu={itemContextMenu}
data-folder-id={folderId}
onClick={() => {
folderItem_click(folderId);
}}
onDoubleClick={onFolderToggleClick_}
>
{doRenderFolderIcon()}<StyledSpanFix className="title" style={{ lineHeight: 0 }}>{folderTitle}</StyledSpanFix>
{shareIcon} {noteCountComp}
</StyledListItemAnchor>
</StyledListItem>
);
}
const menuUtils = new MenuUtils(CommandService.instance());
const SidebarComponent = (props: Props) => { const SidebarComponent = (props: Props) => {
const folderItemsOrder_ = useRef<string[]>();
folderItemsOrder_.current = [];
const tagItemsOrder_ = useRef<string[]>();
tagItemsOrder_.current = [];
const rootRef = useRef(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const anchorItemRefs = useRef<Record<string, any>>({});
// 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<PluginStates>(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_: ItemDragListener = useCallback(event => {
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_: 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);
alert(error.message);
}
}, []);
const onTagDrop_: ItemDragListener = 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 onFolderToggleClick_: ItemClickListener = useCallback(event => {
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: 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');
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();
if (itemId === getTrashFolderId()) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('emptyTrash')),
);
menu.popup({ window: bridge().window() });
return;
}
let item = null;
if (itemType === BaseModel.TYPE_FOLDER) {
item = BaseModel.byId(props.folders, itemId);
}
const isDeleted = item ? !!item.deleted_time : false;
if (!isDeleted) {
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)),
);
}
}
} else {
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('restoreFolder', 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: TagEntity|undefined) => {
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_ = useCallback(() => {
props.dispatch({
type: 'SMART_FILTER_SELECT',
id: ALL_NOTES_FILTER_ID,
});
}, [props.dispatch]);
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 ? <StyledNoteCount className="note-count-label">{count}</StyledNoteCount> : null;
};
const renderExpandIcon = (theme: ThemeStyle, isExpanded: boolean, isVisible: boolean) => {
const style: React.CSSProperties = {
width: 16, maxWidth: 16, opacity: 0.5, fontSize: Math.round(theme.toolbarIconSize * 0.8), display: 'flex', justifyContent: 'center',
};
if (!isVisible) style.visibility = 'hidden';
return <i className={isExpanded ? 'fas fa-caret-down' : 'fas fa-caret-right'} style={style}></i>;
};
const toggleAllNotesContextMenu = useCallback(() => {
const menu = new Menu();
if (Setting.value('notes.perFolderSortOrderEnabled')) {
menu.append(new MenuItem({
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', ALL_NOTES_FILTER_ID),
type: 'checkbox',
checked: PerFolderSortOrderService.isSet(ALL_NOTES_FILTER_ID),
}));
}
menu.popup({ window: bridge().window() });
}, []);
const renderAllNotesItem = (theme: ThemeStyle, selected: boolean) => {
return (
<StyledListItem key="allNotesHeader" selected={selected} className={'list-item-container list-item-depth-0 all-notes'} isSpecialItem={true}>
<StyledExpandLink>{renderExpandIcon(theme, false, false)}</StyledExpandLink>
<StyledAllNotesIcon className="icon-notes"/>
<StyledListItemAnchor
className="list-item"
isSpecialItem={true}
href="#"
selected={selected}
onClick={onAllNotesClick_}
onContextMenu={toggleAllNotesContextMenu}
>
{_('All notes')}
</StyledListItemAnchor>
</StyledListItem>
);
};
const renderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) =>{
const anchorRef = anchorItemRef('folder', folder.id);
const isExpanded = props.collapsedFolderIds.indexOf(folder.id) < 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
let noteCount = (folder as any).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) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
noteCount -= (props.folders[i] as any).note_count;
}
}
}
return <FolderItem
key={folder.id}
folderId={folder.id}
folderTitle={Folder.displayTitle(folder)}
folderIcon={Folder.unserializeIcon(folder.icon)}
themeId={props.themeId}
depth={depth}
selected={selected}
isExpanded={isExpanded}
hasChildren={hasChildren}
anchorRef={anchorRef}
noteCount={noteCount}
onFolderDragStart_={onFolderDragStart_}
onFolderDragOver_={onFolderDragOver_}
onFolderDrop_={onFolderDrop_}
itemContextMenu={itemContextMenu}
folderItem_click={folderItem_click}
onFolderToggleClick_={onFolderToggleClick_}
shareId={folder.share_id}
parentId={folder.parent_id}
showFolderIcon={showFolderIcons}
/>;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
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 (
<StyledListItem selected={selected}
className={`list-item-container ${selected ? 'selected' : ''}`}
key={tag.id}
onDrop={onTagDrop_}
data-tag-id={tag.id}
>
<StyledExpandLink>{renderExpandIcon(theme, false, false)}</StyledExpandLink>
<StyledListItemAnchor
ref={anchorRef}
className="list-item"
href="#"
selected={selected}
data-id={tag.id}
data-type={BaseModel.TYPE_TAG}
onContextMenu={itemContextMenu}
onClick={() => {
tagItem_click(tag);
}}
>
<StyledSpanFix className="tag-label">{Tag.displayTitle(tag)}</StyledSpanFix>
{noteCount}
</StyledListItemAnchor>
</StyledListItem>
);
};
const renderHeader = (
key: string,
label: string,
iconName: string,
contextMenuHandler: ItemContextMenuListener|null = null,
onPlusButtonClick: ItemClickListener|null = null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
extraProps: any = {},
) => {
const headerClick = extraProps.onClick || null;
delete extraProps.onClick;
const ref = anchorItemRef('headers', key);
return (
<div key={key} style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<StyledHeader
ref={ref}
{...extraProps}
onContextMenu={contextMenuHandler}
onClick={(event: MouseEvent) => {
// if a custom click event is attached, trigger that.
if (headerClick) {
headerClick(key, event);
}
onHeaderClick_(key);
}}
>
<StyledHeaderIcon className={iconName}/>
<StyledHeaderLabel>{label}</StyledHeaderLabel>
</StyledHeader>
{ onPlusButtonClick && <StyledAddButton onClick={onPlusButtonClick} iconName="fas fa-plus" level={ButtonLevel.SidebarSecondary}/> }
</div>
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onKeyDown = useCallback((event: KeyboardEvent) => {
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,
});
focus('SideBar::onKeyDown', focusItem.ref.current);
}
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 renderSynchronizeButton = (type: string) => {
const label = type === 'sync' ? _('Synchronise') : _('Cancel'); const label = type === 'sync' ? _('Synchronise') : _('Cancel');
const iconAnimation = type !== 'sync' ? 'icon-infinite-rotation 1s linear infinite' : ''; const iconAnimation = type !== 'sync' ? 'icon-infinite-rotation 1s linear infinite' : '';
@ -778,61 +41,8 @@ const SidebarComponent = (props: Props) => {
); );
}; };
const onAddFolderButtonClick = useCallback(() => {
void CommandService.instance().execute('newFolder');
}, []);
const theme = themeStyle(props.themeId); 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 = renderFolders(props, renderFolderItem);
const folderItems = [renderAllNotesItem(theme, allNotesSelected)].concat(result.items);
folderItemsOrder_.current = result.order;
items.push(
<div
className={`folders ${props.folderHeaderIsExpanded ? 'expanded' : ''}`}
key="folder_items"
style={foldersStyle}
>
{folderItems}
</div>,
);
}
items.push(
renderHeader('tagHeader', _('Tags'), 'icon-tags', null, null, {
toggleblock: 1,
}),
);
if (props.tags.length) {
const result = renderTags(props, renderTag);
const tagItems = result.items;
tagItemsOrder_.current = result.order;
items.push(
<div className="tags" key="tag_items" style={{ display: props.tagHeaderIsExpanded ? 'block' : 'none' }}>
{tagItems}
</div>,
);
}
let decryptionReportText = ''; let decryptionReportText = '';
if (props.decryptionWorker && props.decryptionWorker.state !== 'idle' && props.decryptionWorker.itemCount) { if (props.decryptionWorker && props.decryptionWorker.state !== 'idle' && props.decryptionWorker.itemCount) {
decryptionReportText = _('Decrypting items: %d/%d', props.decryptionWorker.itemIndex + 1, props.decryptionWorker.itemCount); decryptionReportText = _('Decrypting items: %d/%d', props.decryptionWorker.itemIndex + 1, props.decryptionWorker.itemCount);
@ -864,8 +74,8 @@ const SidebarComponent = (props: Props) => {
); );
return ( return (
<StyledRoot ref={rootRef} onKeyDown={onKeyDown} className="sidebar"> <StyledRoot className="sidebar">
<div style={{ flex: 1, overflowX: 'hidden', overflowY: 'auto' }}>{items}</div> <div style={{ flex: 1 }}><FolderAndTagList/></div>
<div style={{ flex: 0, padding: theme.mainPadding }}> <div style={{ flex: 0, padding: theme.mainPadding }}>
{syncReportComp} {syncReportComp}
{syncButton} {syncButton}
@ -876,24 +86,16 @@ const SidebarComponent = (props: Props) => {
const mapStateToProps = (state: AppState) => { const mapStateToProps = (state: AppState) => {
return { return {
folders: state.folders,
tags: state.tags,
searches: state.searches, searches: state.searches,
syncStarted: state.syncStarted, syncStarted: state.syncStarted,
syncReport: state.syncReport, syncReport: state.syncReport,
selectedFolderId: state.selectedFolderId,
selectedTagId: state.selectedTagId,
selectedSearchId: state.selectedSearchId, selectedSearchId: state.selectedSearchId,
selectedSmartFilterId: state.selectedSmartFilterId, selectedSmartFilterId: state.selectedSmartFilterId,
notesParentType: state.notesParentType,
locale: state.settings.locale, locale: state.settings.locale,
themeId: state.settings.theme, themeId: state.settings.theme,
collapsedFolderIds: state.collapsedFolderIds, collapsedFolderIds: state.collapsedFolderIds,
decryptionWorker: state.decryptionWorker, decryptionWorker: state.decryptionWorker,
resourceFetcher: state.resourceFetcher, resourceFetcher: state.resourceFetcher,
plugins: state.pluginService.plugins,
tagHeaderIsExpanded: state.settings.tagHeaderIsExpanded,
folderHeaderIsExpanded: state.settings.folderHeaderIsExpanded,
}; };
}; };

View File

@ -2,7 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import layoutItemProp from '../../ResizableLayout/utils/layoutItemProp'; import layoutItemProp from '../../ResizableLayout/utils/layoutItemProp';
import { AppState } from '../../../app.reducer'; import { AppState } from '../../../app.reducer';
import { focus } from '@joplin/lib/utils/focusHandler'; import { SidebarCommandRuntimeProps } from '../types';
export const declaration: CommandDeclaration = { export const declaration: CommandDeclaration = {
name: 'focusElementSideBar', name: 'focusElementSideBar',
@ -10,29 +10,13 @@ export const declaration: CommandDeclaration = {
parentLabel: () => _('Focus'), parentLabel: () => _('Focus'),
}; };
export interface RuntimeProps { export const runtime = (props: SidebarCommandRuntimeProps): CommandRuntime => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
getSelectedItem(): any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
getFirstAnchorItemRef(type: string): any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
anchorItemRefs: any;
}
export const runtime = (props: RuntimeProps): CommandRuntime => {
return { return {
execute: async (context: CommandContext) => { execute: async (context: CommandContext) => {
const sidebarVisible = layoutItemProp((context.state as AppState).mainLayout, 'sideBar', 'visible'); const sidebarVisible = layoutItemProp((context.state as AppState).mainLayout, 'sideBar', 'visible');
if (sidebarVisible) { if (sidebarVisible) {
const item = props.getSelectedItem(); props.focusSidebar();
if (item) {
const anchorRef = props.anchorItemRefs.current[item.type][item.id];
if (anchorRef) focus('focusElementSideBar1', anchorRef.current);
} else {
const anchorRef = props.getFirstAnchorItemRef('folder');
if (anchorRef) focus('focusElementSideBar2', anchorRef.current);
}
} }
}, },

View File

@ -0,0 +1,82 @@
import { MutableRefObject, RefObject, useCallback, useEffect, useMemo, useRef } from 'react';
import { ListItem } from '../types';
import ItemList from '../../ItemList';
import { focus } from '@joplin/lib/utils/focusHandler';
interface Props {
itemListRef: RefObject<ItemList<ListItem>>;
selectedListElement: HTMLElement|null;
selectedIndex: number;
listItems: ListItem[];
}
const useFocusAfterNextRenderHandler = (
shouldFocusAfterNextRender: MutableRefObject<boolean>,
selectedListElement: HTMLElement|null,
) => {
useEffect(() => {
if (!shouldFocusAfterNextRender.current || !selectedListElement) return;
focus('FolderAndTagList/useFocusHandler/afterRender', selectedListElement);
shouldFocusAfterNextRender.current = false;
}, [selectedListElement, shouldFocusAfterNextRender]);
};
const useRefocusOnSelectionChangeHandler = (
itemListRef: RefObject<ItemList<ListItem>>,
shouldFocusAfterNextRender: MutableRefObject<boolean>,
listItems: ListItem[],
selectedIndex: number,
) => {
// We keep track of the key to avoid scrolling unnecessarily. For example, when the
// selection's index changes because a notebook is expanded/collapsed, we don't necessarily
// want to scroll the selection into view.
const lastSelectedItemKey = useRef('');
const selectedItemKey = useMemo(() => {
if (selectedIndex >= 0 && selectedIndex < listItems.length) {
return listItems[selectedIndex].key;
} else {
// When nothing is selected, re-use the key from before.
// This prevents the view from scrolling when a dropdown containing the
// selection is closed, then opened again.
return lastSelectedItemKey.current;
}
}, [listItems, selectedIndex]);
lastSelectedItemKey.current = selectedItemKey;
const selectedIndexRef = useRef(selectedIndex);
selectedIndexRef.current = selectedIndex;
useEffect(() => {
if (!itemListRef.current || !selectedItemKey) return;
const hasFocus = !!itemListRef.current.container.querySelector(':scope :focus');
shouldFocusAfterNextRender.current = hasFocus;
if (hasFocus) {
itemListRef.current.makeItemIndexVisible(selectedIndexRef.current);
}
}, [selectedItemKey, itemListRef, shouldFocusAfterNextRender]);
};
const useFocusHandler = (props: Props) => {
const { itemListRef, selectedListElement, selectedIndex, listItems } = props;
// When set to true, when selectedListElement next changes, select it.
const shouldFocusAfterNextRender = useRef(false);
useRefocusOnSelectionChangeHandler(itemListRef, shouldFocusAfterNextRender, listItems, selectedIndex);
useFocusAfterNextRenderHandler(shouldFocusAfterNextRender, selectedListElement);
const focusSidebar = useCallback(() => {
if (!selectedListElement || !itemListRef.current.isIndexVisible(selectedIndex)) {
itemListRef.current.makeItemIndexVisible(selectedIndex);
shouldFocusAfterNextRender.current = true;
} else {
focus('FolderAndTagList/useFocusHandler/focusSidebar', selectedListElement);
}
}, [selectedListElement, selectedIndex, itemListRef]);
return { focusSidebar };
};
export default useFocusHandler;

View File

@ -0,0 +1,434 @@
import * as React from 'react';
import { DragEventHandler, MouseEventHandler, useCallback, useMemo, useRef } from 'react';
import { ItemClickListener, ItemDragListener, ListItem, ListItemType } from '../types';
import TagItem, { TagLinkClickEvent } from '../listItemComponents/TagItem';
import { Dispatch } from 'redux';
import { clipboard } 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 { AppState } from '../../../app.reducer';
import { store } from '@joplin/lib/reducer';
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 stateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
import Setting from '@joplin/lib/models/Setting';
import PerFolderSortOrderService from '../../../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';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
const logger = Logger.create('useOnRenderItem');
interface Props {
dispatch: Dispatch;
themeId: number;
plugins: PluginStates;
folders: FolderEntity[];
collapsedFolderIds: string[];
selectedIndex: number;
onSelectedElementShown: (element: HTMLElement)=> void;
}
type ItemContextMenuListener = MouseEventHandler<HTMLElement>;
const menuUtils = new MenuUtils(CommandService.instance());
const useOnRenderItem = (props: Props) => {
const pluginsRef = useRef<PluginStates>(null);
pluginsRef.current = props.plugins;
const foldersRef = useRef<FolderEntity[]>(null);
foldersRef.current = props.folders;
const tagItem_click = useCallback(({ tag }: TagLinkClickEvent) => {
props.dispatch({
type: 'TAG_SELECT',
id: tag ? tag.id : null,
});
}, [props.dispatch]);
const onTagDrop_: DragEventHandler<HTMLElement> = 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 header_contextMenu = useCallback(async () => {
const menu = new Menu();
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder')),
);
menu.popup({ window: bridge().window() });
}, []);
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');
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();
if (itemId === getTrashFolderId()) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('emptyTrash')),
);
menu.popup({ window: bridge().window() });
return;
}
let item = null;
if (itemType === BaseModel.TYPE_FOLDER) {
item = BaseModel.byId(foldersRef.current, itemId);
}
const isDeleted = item ? !!item.deleted_time : false;
if (!isDeleted) {
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)),
);
}
}
} else {
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('restoreFolder', itemId)),
);
}
}
menu.popup({ window: bridge().window() });
}, [props.dispatch, pluginsRef]);
const onFolderDragStart_: ItemDragListener = useCallback(event => {
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_: 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);
alert(error.message);
}
}, []);
const onFolderToggleClick_: ItemClickListener = useCallback(event => {
const folderId = event.currentTarget.getAttribute('data-folder-id');
props.dispatch({
type: 'FOLDER_TOGGLE',
id: folderId,
});
}, [props.dispatch]);
const folderItem_click = useCallback((folderId: string) => {
props.dispatch({
type: 'FOLDER_SELECT',
id: folderId ? folderId : null,
});
}, [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 selectedIndexRef = useRef(props.selectedIndex);
selectedIndexRef.current = props.selectedIndex;
return useCallback((item: ListItem, index: number) => {
const selected = props.selectedIndex === index;
const anchorRefCallback = selected ? (
(element: HTMLElement) => {
if (selectedIndexRef.current === index) {
props.onSelectedElementShown(element);
}
}
) : null;
if (item.kind === ListItemType.Tag) {
const tag = item.tag;
return <TagItem
key={item.key}
anchorRef={anchorRefCallback}
selected={selected}
onClick={tagItem_click}
onTagDrop={onTagDrop_}
onContextMenu={onItemContextMenu}
tag={tag}
/>;
} else if (item.kind === ListItemType.Folder) {
const folder = item.folder;
const isExpanded = props.collapsedFolderIds.indexOf(folder.id) < 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
let noteCount = (folder as any).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) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
noteCount -= (props.folders[i] as any).note_count;
}
}
}
return <FolderItem
key={item.key}
anchorRef={anchorRefCallback}
selected={selected}
folderId={folder.id}
folderTitle={Folder.displayTitle(folder)}
folderIcon={Folder.unserializeIcon(folder.icon)}
depth={item.depth}
isExpanded={isExpanded}
hasChildren={item.hasChildren}
noteCount={noteCount}
onFolderDragStart_={onFolderDragStart_}
onFolderDragOver_={onFolderDragOver_}
onFolderDrop_={onFolderDrop_}
itemContextMenu={onItemContextMenu}
folderItem_click={folderItem_click}
onFolderToggleClick_={onFolderToggleClick_}
shareId={folder.share_id}
parentId={folder.parent_id}
showFolderIcon={showFolderIcons}
/>;
} else if (item.kind === ListItemType.Header) {
return <HeaderItem
key={item.id}
item={item}
anchorRef={anchorRefCallback}
contextMenuHandler={header_contextMenu}
onDrop={item.supportsFolderDrop ? onFolderDrop_ : null}
/>;
} else if (item.kind === ListItemType.AllNotes) {
return <AllNotesItem
key={item.key}
selected={selected}
anchorRef={anchorRefCallback}
/>;
} else if (item.kind === ListItemType.Spacer) {
return (
<a key={item.key} className='sidebar-spacer-item' ref={anchorRefCallback} aria-label={_('Spacer')}></a>
);
} else {
const exhaustivenessCheck: never = item;
return exhaustivenessCheck;
}
}, [
folderItem_click,
header_contextMenu,
onFolderDragOver_,
onFolderDragStart_,
onFolderDrop_,
onFolderToggleClick_,
onItemContextMenu,
onTagDrop_,
props.collapsedFolderIds,
props.folders,
showFolderIcons,
tagItem_click,
props.selectedIndex,
props.onSelectedElementShown,
]);
};
export default useOnRenderItem;

View File

@ -0,0 +1,51 @@
import { Dispatch } from 'redux';
import { ListItem, ListItemType, SetSelectedIndexCallback } from '../types';
import { KeyboardEventHandler, useCallback } from 'react';
import CommandService from '@joplin/lib/services/CommandService';
interface Props {
dispatch: Dispatch;
listItems: ListItem[];
selectedIndex: number;
updateSelectedIndex: SetSelectedIndexCallback;
}
const useOnSidebarKeyDownHandler = (props: Props) => {
const { updateSelectedIndex, listItems, selectedIndex, dispatch } = props;
return useCallback<KeyboardEventHandler<HTMLElement>>((event) => {
const selectedItem = listItems[selectedIndex];
if (selectedItem && selectedItem.kind === ListItemType.Folder && event.code === 'Space') {
event.preventDefault();
dispatch({
type: 'FOLDER_TOGGLE',
id: selectedItem.folder.id,
});
} else if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
event.preventDefault();
}
let indexChange = 0;
if (event.code === 'ArrowUp') {
indexChange = -1;
} else if (event.code === 'ArrowDown') {
indexChange = 1;
} else if (event.code === 'Tab') {
event.preventDefault();
if (event.shiftKey) {
void CommandService.instance().execute('focusElement', 'noteBody');
} else {
void CommandService.instance().execute('focusElement', 'noteList');
}
}
if (indexChange !== 0) {
event.preventDefault();
updateSelectedIndex(selectedIndex + indexChange);
}
}, [selectedIndex, listItems, updateSelectedIndex, dispatch]);
};
export default useOnSidebarKeyDownHandler;

View File

@ -0,0 +1,88 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ListItem, ListItemType } from '../types';
import { isFolderSelected, isTagSelected } from '@joplin/lib/components/shared/side-menu-shared';
import { Dispatch } from 'redux';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
interface Props {
dispatch: Dispatch;
listItems: ListItem[];
notesParentType: string;
selectedTagId: string;
selectedFolderId: string;
selectedSmartFilterId: string;
}
const useSelectedSidebarIndex = (props: Props) => {
const appStateSelectedIndex = useMemo(() => {
for (let i = 0; i < props.listItems.length; i++) {
const listItem = props.listItems[i];
let selected = false;
if (listItem.kind === ListItemType.AllNotes) {
selected = props.selectedSmartFilterId === ALL_NOTES_FILTER_ID && props.notesParentType === 'SmartFilter';
} else if (listItem.kind === ListItemType.Header || listItem.kind === ListItemType.Spacer) {
selected = false;
} else if (listItem.kind === ListItemType.Folder) {
selected = isFolderSelected(listItem.folder, { selectedFolderId: props.selectedFolderId, notesParentType: props.notesParentType });
} else if (listItem.kind === ListItemType.Tag) {
selected = isTagSelected(listItem.tag, { selectedTagId: props.selectedTagId, notesParentType: props.notesParentType });
} else {
const exhaustivenessCheck: never = listItem;
return exhaustivenessCheck;
}
if (selected) {
return i;
}
}
return -1;
}, [props.listItems, props.selectedFolderId, props.selectedTagId, props.selectedSmartFilterId, props.notesParentType]);
// Not all list items correspond with selectable Joplin folders/tags, but we want to
// be able to select them anyway. This is handled with selectedIndexOverride.
//
// When selectedIndexOverride >= 0, it corresponds to the index of a selected item with no
// specific note parent item (e.g. a header).
const [selectedIndexOverride, setSelectedIndexOverride] = useState(-1);
useEffect(() => {
setSelectedIndexOverride(-1);
}, [appStateSelectedIndex]);
const updateSelectedIndex = useCallback((newIndex: number) => {
if (newIndex < 0) {
newIndex = 0;
} else if (newIndex >= props.listItems.length) {
newIndex = props.listItems.length - 1;
}
const newItem = props.listItems[newIndex];
let newOverrideIndex = -1;
if (newItem.kind === ListItemType.AllNotes) {
props.dispatch({
type: 'SMART_FILTER_SELECT',
id: ALL_NOTES_FILTER_ID,
});
} else if (newItem.kind === ListItemType.Folder) {
props.dispatch({
type: 'FOLDER_SELECT',
id: newItem.folder.id,
});
} else if (newItem.kind === ListItemType.Tag) {
props.dispatch({
type: 'TAG_SELECT',
id: newItem.tag.id,
});
} else {
newOverrideIndex = newIndex;
}
setSelectedIndexOverride(newOverrideIndex);
}, [props.listItems, props.dispatch]);
const selectedIndex = selectedIndexOverride === -1 ? appStateSelectedIndex : selectedIndexOverride;
return { selectedIndex, updateSelectedIndex };
};
export default useSelectedSidebarIndex;

View File

@ -0,0 +1,24 @@
import CommandService from '@joplin/lib/services/CommandService';
import { useEffect } from 'react';
import commands from '../commands';
import { SidebarCommandRuntimeProps } from '../types';
interface Props {
focusSidebar: ()=> void;
}
const useSidebarCommandHandler = ({ focusSidebar }: Props) => {
useEffect(() => {
const runtimeProps: SidebarCommandRuntimeProps = {
focusSidebar,
};
CommandService.instance().componentRegisterCommands(runtimeProps, commands);
return () => {
CommandService.instance().componentUnregisterCommands(commands);
};
}, [focusSidebar]);
};
export default useSidebarCommandHandler;

View File

@ -0,0 +1,98 @@
import { useMemo } from 'react';
import { FolderListItem, HeaderId, HeaderListItem, ListItem, ListItemType, TagListItem } from '../types';
import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import { renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared';
import { _ } from '@joplin/lib/locale';
import CommandService from '@joplin/lib/services/CommandService';
import Setting from '@joplin/lib/models/Setting';
interface Props {
tags: TagsWithNoteCountEntity[];
folders: FolderEntity[];
collapsedFolderIds: string[];
folderHeaderIsExpanded: boolean;
tagHeaderIsExpanded: boolean;
}
const onAddFolderButtonClick = () => {
void CommandService.instance().execute('newFolder');
};
const onHeaderClick = (headerId: HeaderId) => {
const settingKey = headerId === HeaderId.TagHeader ? 'tagHeaderIsExpanded' : 'folderHeaderIsExpanded';
const current = Setting.value(settingKey);
Setting.setValue(settingKey, !current);
};
const useSidebarListData = (props: Props): ListItem[] => {
const tagItems = useMemo(() => {
return renderTags<ListItem>(props.tags, (tag): TagListItem => {
return {
kind: ListItemType.Tag,
tag,
key: tag.id,
};
});
}, [props.tags]);
const folderItems = useMemo(() => {
const renderProps = {
folders: props.folders,
collapsedFolderIds: props.collapsedFolderIds,
};
return renderFolders<ListItem>(renderProps, (folder, hasChildren, depth): FolderListItem => {
return {
kind: ListItemType.Folder,
folder,
hasChildren,
depth,
key: folder.id,
};
});
}, [props.folders, props.collapsedFolderIds]);
return useMemo(() => {
const foldersHeader: HeaderListItem = {
kind: ListItemType.Header,
label: _('Notebooks'),
iconName: 'icon-notebooks',
id: HeaderId.FolderHeader,
key: HeaderId.FolderHeader,
onClick: onHeaderClick,
onPlusButtonClick: onAddFolderButtonClick,
extraProps: {
['data-folder-id']: '',
},
supportsFolderDrop: true,
};
const foldersSectionContent: ListItem[] = props.folderHeaderIsExpanded ? [
{ kind: ListItemType.AllNotes, key: 'all-notes' },
...folderItems.items,
{ kind: ListItemType.Spacer, key: 'after-folders-spacer' },
] : [];
const tagsHeader: HeaderListItem = {
kind: ListItemType.Header,
label: _('Tags'),
iconName: 'icon-tags',
id: HeaderId.TagHeader,
key: HeaderId.TagHeader,
onClick: onHeaderClick,
onPlusButtonClick: null,
extraProps: { },
supportsFolderDrop: false,
};
const tagsSectionContent: ListItem[] = props.tagHeaderIsExpanded ? tagItems.items : [];
const items: ListItem[] = [
foldersHeader,
...foldersSectionContent,
tagsHeader,
...tagsSectionContent,
];
return items;
}, [tagItems, folderItems, props.folderHeaderIsExpanded, props.tagHeaderIsExpanded]);
};
export default useSidebarListData;

View File

@ -0,0 +1,67 @@
import * as React from 'react';
import { StyledAllNotesIcon, StyledListItem, StyledListItemAnchor } from '../styles';
import { useCallback } from 'react';
import { Dispatch } from 'redux';
import bridge from '../../../services/bridge';
import Setting from '@joplin/lib/models/Setting';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
import { _ } from '@joplin/lib/locale';
import { connect } from 'react-redux';
import EmptyExpandLink from './EmptyExpandLink';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
interface Props {
dispatch: Dispatch;
selected: boolean;
anchorRef: React.Ref<HTMLAnchorElement>;
}
const menuUtils = new MenuUtils(CommandService.instance());
const AllNotesItem: React.FC<Props> = props => {
const onAllNotesClick_ = useCallback(() => {
props.dispatch({
type: 'SMART_FILTER_SELECT',
id: ALL_NOTES_FILTER_ID,
});
}, [props.dispatch]);
const toggleAllNotesContextMenu = useCallback(() => {
const menu = new Menu();
if (Setting.value('notes.perFolderSortOrderEnabled')) {
menu.append(new MenuItem({
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', ALL_NOTES_FILTER_ID),
type: 'checkbox',
checked: PerFolderSortOrderService.isSet(ALL_NOTES_FILTER_ID),
}));
}
menu.popup({ window: bridge().window() });
}, []);
return (
<StyledListItem key="allNotesHeader" selected={props.selected} className={'list-item-container list-item-depth-0 all-notes'} isSpecialItem={true}>
<EmptyExpandLink/>
<StyledAllNotesIcon className="icon-notes"/>
<StyledListItemAnchor
ref={props.anchorRef}
className="list-item"
isSpecialItem={true}
href="#"
selected={props.selected}
onClick={onAllNotesClick_}
onContextMenu={toggleAllNotesContextMenu}
>
{_('All notes')}
</StyledListItemAnchor>
</StyledListItem>
);
};
export default connect()(AllNotesItem);

View File

@ -0,0 +1,11 @@
import * as React from 'react';
import ExpandIcon from './ExpandIcon';
interface Props {
}
const EmptyExpandLink: React.FC<Props> = _props => {
return <a className='sidebar-expand-link'><ExpandIcon isVisible={false} isExpanded={false}/></a>;
};
export default EmptyExpandLink;

View File

@ -0,0 +1,33 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
type ExpandIconProps = {
isExpanded: boolean;
isVisible: true;
targetTitle: string;
}|{
isExpanded: boolean;
isVisible: false;
targetTitle?: string;
};
const ExpandIcon: React.FC<ExpandIconProps> = props => {
const classNames = ['sidebar-expand-icon'];
if (props.isVisible) classNames.push('-visible');
classNames.push(props.isExpanded ? 'fas fa-caret-down' : 'fas fa-caret-right');
// Referencing the name of the item we expand/collapse is both good for accessibility
// and makes writing tests easier.
const getLabel = () => {
if (!props.isVisible) {
return undefined;
}
if (props.isExpanded) {
return _('Collapse %s', props.targetTitle);
}
return _('Expand %s', props.targetTitle);
};
return <i className={classNames.join(' ')} aria-label={getLabel()}></i>;
};
export default ExpandIcon;

View File

@ -0,0 +1,24 @@
import * as React from 'react';
import { MouseEventHandler } from 'react';
import ExpandIcon from './ExpandIcon';
import EmptyExpandLink from './EmptyExpandLink';
interface ExpandLinkProps {
folderId: string;
folderTitle: string;
hasChildren: boolean;
isExpanded: boolean;
onClick: MouseEventHandler<HTMLElement>;
}
const ExpandLink: React.FC<ExpandLinkProps> = props => {
return props.hasChildren ? (
<a className='sidebar-expand-link' href="#" data-folder-id={props.folderId} onClick={props.onClick}>
<ExpandIcon isVisible={true} isExpanded={props.isExpanded} targetTitle={props.folderTitle}/>
</a>
) : (
<EmptyExpandLink/>
);
};
export default ExpandLink;

View File

@ -0,0 +1,89 @@
import * as React from 'react';
import { FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
import ExpandLink from './ExpandLink';
import { StyledListItem, StyledListItemAnchor, StyledNoteCount, StyledShareIcon, StyledSpanFix } from '../styles';
import { ItemClickListener, ItemContextMenuListener, ItemDragListener } from '../types';
import FolderIconBox from '../../FolderIconBox';
import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash';
import Folder from '@joplin/lib/models/Folder';
import { ModelType } from '@joplin/lib/BaseModel';
const renderFolderIcon = (folderIcon: FolderIcon) => {
if (!folderIcon) {
const defaultFolderIcon: FolderIcon = {
dataUrl: '',
emoji: '',
name: 'far fa-folder',
type: FolderIconType.FontAwesome,
};
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox opacity={0.7} folderIcon={defaultFolderIcon}/></div>;
}
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox folderIcon={folderIcon}/></div>;
};
interface FolderItemProps {
hasChildren: boolean;
showFolderIcon: boolean;
isExpanded: boolean;
parentId: string;
depth: number;
folderId: string;
folderTitle: string;
folderIcon: FolderIcon;
noteCount: number;
onFolderDragStart_: ItemDragListener;
onFolderDragOver_: ItemDragListener;
onFolderDrop_: ItemDragListener;
itemContextMenu: ItemContextMenuListener;
folderItem_click: (folderId: string)=> void;
onFolderToggleClick_: ItemClickListener;
shareId: string;
selected: boolean;
anchorRef: React.Ref<HTMLElement>;
}
function FolderItem(props: FolderItemProps) {
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
const noteCountComp = noteCount ? <StyledNoteCount className="note-count-label">{noteCount}</StyledNoteCount> : null;
const shareIcon = shareId && !parentId ? <StyledShareIcon className="fas fa-share-alt"></StyledShareIcon> : null;
const draggable = ![getTrashFolderId(), Folder.conflictFolderId()].includes(folderId);
const doRenderFolderIcon = () => {
if (folderId === getTrashFolderId()) {
return renderFolderIcon(getTrashFolderIcon(FolderIconType.FontAwesome));
}
if (!showFolderIcon) return null;
return renderFolderIcon(folderIcon);
};
return (
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={draggable} data-folder-id={folderId}>
<ExpandLink hasChildren={hasChildren} folderTitle={folderTitle} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
<StyledListItemAnchor
ref={props.anchorRef}
className="list-item"
isConflictFolder={folderId === Folder.conflictFolderId()}
href="#"
selected={selected}
shareId={shareId}
data-id={folderId}
data-type={ModelType.Folder}
onContextMenu={itemContextMenu}
data-folder-id={folderId}
onClick={() => {
folderItem_click(folderId);
}}
onDoubleClick={onFolderToggleClick_}
>
{doRenderFolderIcon()}<StyledSpanFix className="title">{folderTitle}</StyledSpanFix>
{shareIcon} {noteCountComp}
</StyledListItemAnchor>
</StyledListItem>
);
}
export default FolderItem;

View File

@ -0,0 +1,53 @@
import * as React from 'react';
import { useCallback } from 'react';
import { ButtonLevel } from '../../Button/Button';
import { StyledAddButton, StyledHeader, StyledHeaderIcon, StyledHeaderLabel } from '../styles';
import { HeaderListItem, ItemContextMenuListener } from '../types';
import { _ } from '@joplin/lib/locale';
interface Props {
item: HeaderListItem;
contextMenuHandler: ItemContextMenuListener|null;
onDrop: React.DragEventHandler|null;
anchorRef: React.Ref<HTMLElement>;
}
const HeaderItem: React.FC<Props> = props => {
const item = props.item;
const onItemClick = item.onClick;
const itemId = item.id;
const onClick: React.MouseEventHandler<HTMLElement> = useCallback(event => {
if (onItemClick) {
onItemClick(itemId, event);
}
}, [onItemClick, itemId]);
const addButton = <StyledAddButton
iconLabel={_('New')}
onClick={item.onPlusButtonClick}
iconName='fas fa-plus'
level={ButtonLevel.SidebarSecondary}
/>;
return (
<div
className='sidebar-header-container'
{...item.extraProps}
onDrop={props.onDrop}
>
<StyledHeader
onContextMenu={props.contextMenuHandler}
onClick={onClick}
tabIndex={0}
ref={props.anchorRef}
>
<StyledHeaderIcon className={item.iconName}/>
<StyledHeaderLabel>{item.label}</StyledHeaderLabel>
</StyledHeader>
{ item.onPlusButtonClick && addButton }
</div>
);
};
export default HeaderItem;

View File

@ -0,0 +1,14 @@
import * as React from 'react';
import { StyledNoteCount } from '../styles';
interface Props {
count: number;
}
const NoteCount: React.FC<Props> = props => {
const count = props.count;
return count ? <StyledNoteCount className="note-count-label">{count}</StyledNoteCount> : null;
};
export default NoteCount;

View File

@ -0,0 +1,59 @@
import Setting from '@joplin/lib/models/Setting';
import * as React from 'react';
import { useCallback } from 'react';
import { StyledListItem, StyledListItemAnchor, StyledSpanFix } from '../styles';
import { TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import BaseModel from '@joplin/lib/BaseModel';
import NoteCount from './NoteCount';
import Tag from '@joplin/lib/models/Tag';
import EmptyExpandLink from './EmptyExpandLink';
export type TagLinkClickEvent = { tag: TagsWithNoteCountEntity|undefined };
interface Props {
selected: boolean;
anchorRef: React.Ref<HTMLElement>;
tag: TagsWithNoteCountEntity;
onTagDrop: React.DragEventHandler<HTMLElement>;
onContextMenu: React.MouseEventHandler<HTMLElement>;
onClick: (event: TagLinkClickEvent)=> void;
}
const TagItem = (props: Props) => {
const { tag, selected } = props;
let noteCount = null;
if (Setting.value('showNoteCounts')) {
const count = Setting.value('showCompletedTodos') ? tag.note_count : tag.note_count - tag.todo_completed_count;
noteCount = <NoteCount count={count}/>;
}
const onClickHandler = useCallback(() => {
props.onClick({ tag });
}, [props.onClick, tag]);
return (
<StyledListItem selected={selected}
className={`list-item-container ${selected ? 'selected' : ''}`}
onDrop={props.onTagDrop}
data-tag-id={tag.id}
>
<EmptyExpandLink/>
<StyledListItemAnchor
ref={props.anchorRef}
className="list-item"
href="#"
selected={selected}
data-id={tag.id}
data-type={BaseModel.TYPE_TAG}
onContextMenu={props.onContextMenu}
onClick={onClickHandler}
>
<StyledSpanFix className="tag-label">{Tag.displayTitle(tag)}</StyledSpanFix>
{noteCount}
</StyledListItemAnchor>
</StyledListItem>
);
};
export default TagItem;

View File

@ -0,0 +1,5 @@
@use 'styles/folder-and-tag-list.scss';
@use 'styles/sidebar-expand-icon.scss';
@use 'styles/sidebar-expand-link.scss';
@use 'styles/sidebar-header-container.scss';
@use 'styles/sidebar-spacer-item.scss';

View File

@ -0,0 +1,14 @@
.folder-and-tag-list {
position: relative;
overflow: hidden;
flex-grow: 1;
height: 100%;
width: 100%;
> .items {
position: absolute;
left: 0;
right: 0;
}
}

View File

@ -90,20 +90,6 @@ export const StyledShareIcon = styled.i`
margin-left: 8px; margin-left: 8px;
`; `;
export const StyledExpandLink = styled.a`
color: ${(props: StyleProps) => props.theme.color2};
cursor: default;
opacity: 0.8;
text-decoration: none;
padding-right: 8px;
display: flex;
align-items: center;
width: 16px;
max-width: 16px;
min-width: 16px;
height: 100%;
`;
export const StyledNoteCount = styled.div` export const StyledNoteCount = styled.div`
color: ${(props: StyleProps) => props.theme.colorFaded2}; color: ${(props: StyleProps) => props.theme.colorFaded2};
padding-left: 8px; padding-left: 8px;

View File

@ -0,0 +1,13 @@
.sidebar-expand-icon {
width: 16px;
max-width: 16px;
opacity: 0.5px;
font-size: calc(var(--joplin-toolbar-icon-size) * 0.8);
display: flex;
justify-content: center;
&:not(.-visible) {
visibility: hidden;
}
}

View File

@ -0,0 +1,14 @@
.sidebar-expand-link {
color: var(--joplin-color2);
cursor: default;
opacity: 0.8;
text-decoration: none;
padding-right: 8px;
display: flex;
align-items: center;
width: 16px;
max-width: 16px;
min-width: 16px;
height: 100%;
}

View File

@ -0,0 +1,6 @@
.sidebar-header-container {
display: flex;
flex-direction: row;
align-items: center;
}

View File

@ -0,0 +1,5 @@
.sidebar-spacer-item {
display: block;
height: 30px;
}

View File

@ -0,0 +1,64 @@
import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import { DragEventHandler, MouseEventHandler, MouseEvent as ReactMouseEvent } from 'react';
export enum HeaderId {
TagHeader = 'tagHeader',
FolderHeader = 'folderHeader',
}
export enum ListItemType {
Header = 'header',
Tag = 'tag',
Folder = 'folder',
AllNotes = 'all-notes',
Spacer = 'spacer',
}
interface BaseListItem {
key: string;
}
export interface HeaderListItem extends BaseListItem {
kind: ListItemType.Header;
label: string;
iconName: string;
id: HeaderId;
onClick: ((headerId: HeaderId, event: ReactMouseEvent<HTMLElement>)=> void)|null;
onPlusButtonClick: MouseEventHandler<HTMLElement>|null;
extraProps: Record<string, string>;
supportsFolderDrop: boolean;
}
export interface AllNotesListItem extends BaseListItem {
kind: ListItemType.AllNotes;
}
export interface TagListItem extends BaseListItem {
kind: ListItemType.Tag;
tag: TagsWithNoteCountEntity;
}
export interface FolderListItem extends BaseListItem {
kind: ListItemType.Folder;
folder: FolderEntity;
hasChildren: boolean;
depth: number;
}
export interface SpacerListItem extends BaseListItem {
kind: ListItemType.Spacer;
}
export type ListItem = HeaderListItem|AllNotesListItem|TagListItem|FolderListItem|SpacerListItem;
export type SetSelectedIndexCallback = (newIndex: number)=> void;
export type ItemDragListener = DragEventHandler<HTMLElement>;
export type ItemContextMenuListener = MouseEventHandler<HTMLElement>;
export type ItemClickListener = MouseEventHandler<HTMLElement>;
export interface SidebarCommandRuntimeProps {
focusSidebar: ()=> void;
}

View File

@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
// This uses a ResizeObserver -- be careful to prevent infinite loops (should be stopped
// early and print a warning). See https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
const useElementHeight = (container: HTMLElement|null) => {
const [height, setHeight] = useState(container?.clientHeight ?? 0);
useEffect(() => {
if (!container) return () => {};
const observer = new ResizeObserver(() => {
setHeight(container.clientHeight);
});
observer.observe(container);
return () => {
observer.disconnect();
};
}, [container]);
return height;
};
export default useElementHeight;

View File

@ -1,21 +1,25 @@
import { Page, Locator, ElectronApplication } from '@playwright/test'; import { Page, Locator, ElectronApplication } from '@playwright/test';
import NoteEditorScreen from './NoteEditorScreen'; import NoteEditorScreen from './NoteEditorScreen';
import activateMainMenuItem from '../util/activateMainMenuItem'; import activateMainMenuItem from '../util/activateMainMenuItem';
import Sidebar from './Sidebar';
export default class MainScreen { export default class MainScreen {
public readonly newNoteButton: Locator; public readonly newNoteButton: Locator;
public readonly noteListContainer: Locator; public readonly noteListContainer: Locator;
public readonly sidebar: Sidebar;
public readonly dialog: Locator;
public readonly noteEditor: NoteEditorScreen; public readonly noteEditor: NoteEditorScreen;
public constructor(private page: Page) { public constructor(private page: Page) {
this.newNoteButton = page.locator('.new-note-button'); this.newNoteButton = page.locator('.new-note-button');
this.noteListContainer = page.locator('.rli-noteList'); this.noteListContainer = page.locator('.rli-noteList');
this.sidebar = new Sidebar(page, this);
this.dialog = page.locator('.dialog-root');
this.noteEditor = new NoteEditorScreen(page); this.noteEditor = new NoteEditorScreen(page);
} }
public async waitFor() { public async waitFor() {
await this.newNoteButton.waitFor(); await this.newNoteButton.waitFor();
await this.noteEditor.waitFor();
await this.noteListContainer.waitFor(); await this.noteListContainer.waitFor();
} }

View File

@ -0,0 +1,46 @@
import activateMainMenuItem from '../util/activateMainMenuItem';
import type MainScreen from './MainScreen';
import { ElectronApplication, Locator, Page } from '@playwright/test';
export default class Sidebar {
public readonly container: Locator;
public constructor(page: Page, private mainScreen: MainScreen) {
this.container = page.locator('.rli-sideBar');
}
public async createNewFolder(title: string) {
const newFolderButton = this.container.getByRole('button', { name: 'New' });
await newFolderButton.click();
const titleInput = this.mainScreen.dialog.getByLabel('Title');
await titleInput.fill(title);
const submitButton = this.mainScreen.dialog.getByRole('button', { name: 'OK' });
await submitButton.click();
return this.container.getByText(title);
}
private async sortBy(electronApp: ElectronApplication, option: string) {
const success = await activateMainMenuItem(electronApp, option, 'Sort notebooks by');
if (!success) {
throw new Error(`Failed to find menu item: ${option}`);
}
}
public async sortByDate(electronApp: ElectronApplication) {
return this.sortBy(electronApp, 'Updated date');
}
public async sortByTitle(electronApp: ElectronApplication) {
return this.sortBy(electronApp, 'Title');
}
public async forceUpdateSorting(electronApp: ElectronApplication) {
// By default, notebooks will not be in the correct position in the list for about 1 second.
// Change the notebook list sort order to force an immediate refresh.
await this.sortByDate(electronApp);
await this.sortByTitle(electronApp);
}
}

View File

@ -0,0 +1,104 @@
import { test, expect } from './util/test';
import MainScreen from './models/MainScreen';
test.describe('sidebar', () => {
test('should be able to create new folders', async ({ mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
const sidebar = mainScreen.sidebar;
for (let i = 0; i < 3; i++) {
const title = `Test folder ${i}`;
await sidebar.createNewFolder(title);
await expect(sidebar.container.getByText(title)).toBeAttached();
}
// The first folder should still be visible
await expect(sidebar.container.getByText('Test folder 0')).toBeAttached();
});
test('should allow changing the focused folder with the arrow keys', async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
const sidebar = mainScreen.sidebar;
const folderAHeader = await sidebar.createNewFolder('Folder A');
await expect(folderAHeader).toBeVisible();
const folderBHeader = await sidebar.createNewFolder('Folder B');
await expect(folderBHeader).toBeVisible();
await folderBHeader.click();
await sidebar.forceUpdateSorting(electronApp);
await folderBHeader.click();
await mainWindow.keyboard.press('ArrowUp');
await expect(mainWindow.locator(':focus')).toHaveText('Folder A');
await mainWindow.keyboard.press('ArrowDown');
await expect(mainWindow.locator(':focus')).toHaveText('Folder B');
await mainWindow.keyboard.press('ArrowUp');
await expect(mainWindow.locator(':focus')).toHaveText('Folder A');
await mainWindow.keyboard.press('ArrowUp');
await expect(mainWindow.locator(':focus')).toHaveText('All notes');
await mainWindow.keyboard.press('ArrowUp');
await expect(mainWindow.locator(':focus')).toHaveText(/NOTEBOOKS/i);
await mainWindow.keyboard.press('ArrowDown');
await expect(mainWindow.locator(':focus')).toHaveText('All notes');
});
test('should allow changing the parent of a folder by drag-and-drop', async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
const sidebar = mainScreen.sidebar;
const parentFolderHeader = await sidebar.createNewFolder('Parent folder');
await expect(parentFolderHeader).toBeVisible();
const childFolderHeader = await sidebar.createNewFolder('Child folder');
await expect(childFolderHeader).toBeVisible();
await sidebar.forceUpdateSorting(electronApp);
await childFolderHeader.dragTo(parentFolderHeader);
// Verify that it's now a child folder -- expand and collapse the parent
const collapseButton = sidebar.container.getByRole('link', { name: 'Collapse Parent folder' });
await expect(collapseButton).toBeVisible();
await collapseButton.click();
// Should be collapsed
await expect(childFolderHeader).not.toBeAttached();
const expandButton = sidebar.container.getByRole('link', { name: 'Expand Parent folder' });
await expandButton.click();
// Should be possible to move back to the root
const rootFolderHeader = sidebar.container.getByText('Notebooks');
await childFolderHeader.dragTo(rootFolderHeader);
await expect(collapseButton).not.toBeVisible();
await expect(expandButton).not.toBeVisible();
});
test('all notes section should list all notes', async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
const sidebar = mainScreen.sidebar;
const testFolderA = await sidebar.createNewFolder('Folder A');
await expect(testFolderA).toBeAttached();
await sidebar.forceUpdateSorting(electronApp);
await mainScreen.createNewNote('A note in Folder A');
await expect(mainWindow.getByText('A note in Folder A')).toBeAttached();
await mainScreen.createNewNote('Another note in Folder A');
const testFolderB = await sidebar.createNewFolder('Folder B');
await expect(testFolderB).toBeAttached();
await mainScreen.createNewNote('A note in Folder B');
const allNotesButton = sidebar.container.getByText('All notes');
await allNotesButton.click();
await expect(mainWindow.getByText('A note in Folder A')).toBeAttached();
await expect(mainWindow.getByText('Another note in Folder A')).toBeAttached();
await expect(mainWindow.getByText('A note in Folder B')).toBeAttached();
});
});

View File

@ -6,17 +6,22 @@ import type { MenuItem } from 'electron';
// Roughly based on // Roughly based on
// https://github.com/spaceagetv/electron-playwright-helpers/blob/main/src/menu_helpers.ts // https://github.com/spaceagetv/electron-playwright-helpers/blob/main/src/menu_helpers.ts
// `menuItemPath` should be a list of menu labels (e.g. [["&JoplinMainMenu", "&File"], "Synchronise"]). // If given, `parentMenuLabel` should be the label of the menu containing the target item.
const activateMainMenuItem = (electronApp: ElectronApplication, menuItemLabel: string) => { const activateMainMenuItem = (
return electronApp.evaluate(async ({ Menu }, menuItemLabel) => { electronApp: ElectronApplication,
const activateItemInSubmenu = (submenu: MenuItem[]) => { targetItemLabel: string,
parentMenuLabel?: string,
) => {
return electronApp.evaluate(async ({ Menu }, [targetItemLabel, parentMenuLabel]) => {
const activateItemInSubmenu = (submenu: MenuItem[], parentLabel: string) => {
for (const item of submenu) { for (const item of submenu) {
if (item.label === menuItemLabel && item.visible) { const matchesParent = !parentMenuLabel || parentLabel === parentMenuLabel;
if (item.label === targetItemLabel && matchesParent && item.visible) {
// Found! // Found!
item.click(); item.click();
return true; return true;
} else if (item.submenu) { } else if (item.submenu) {
const foundItem = activateItemInSubmenu(item.submenu.items); const foundItem = activateItemInSubmenu(item.submenu.items, item.label);
if (foundItem) { if (foundItem) {
return true; return true;
@ -29,8 +34,8 @@ const activateMainMenuItem = (electronApp: ElectronApplication, menuItemLabel: s
}; };
const appMenu = Menu.getApplicationMenu(); const appMenu = Menu.getApplicationMenu();
return activateItemInSubmenu(appMenu.items); return activateItemInSubmenu(appMenu.items, '');
}, menuItemLabel); }, [targetItemLabel, parentMenuLabel]);
}; };
export default activateMainMenuItem; export default activateMainMenuItem;

View File

@ -9,4 +9,5 @@
@use 'gui/JoplinCloudLoginScreen.scss' as joplin-cloud-login-screen; @use 'gui/JoplinCloudLoginScreen.scss' as joplin-cloud-login-screen;
@use 'gui/NoteListHeader/style.scss' as note-list-header; @use 'gui/NoteListHeader/style.scss' as note-list-header;
@use 'gui/TrashNotification/style.scss' as trash-notification; @use 'gui/TrashNotification/style.scss' as trash-notification;
@use 'gui/Sidebar/style.scss' as sidebar-styles;
@use 'main.scss' as main; @use 'main.scss' as main;

View File

@ -8,7 +8,7 @@ import Synchronizer from '@joplin/lib/Synchronizer';
import NavService from '@joplin/lib/services/NavService'; import NavService from '@joplin/lib/services/NavService';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import { ThemeStyle, themeStyle } from './global-style'; import { ThemeStyle, themeStyle } from './global-style';
import { renderFolders } from '@joplin/lib/components/shared/side-menu-shared'; import { isFolderSelected, renderFolders } from '@joplin/lib/components/shared/side-menu-shared';
import { FolderEntity, FolderIcon, FolderIconType } from '@joplin/lib/services/database/types'; import { FolderEntity, FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
import { AppState } from '../utils/types'; import { AppState } from '../utils/types';
import Setting from '@joplin/lib/models/Setting'; import Setting from '@joplin/lib/models/Setting';
@ -393,7 +393,7 @@ const SideMenuContentComponent = (props: Props) => {
} }
}; };
const renderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) => { const renderFolderItem = (folder: FolderEntity, hasChildren: boolean, depth: number) => {
const theme = themeStyle(props.themeId); const theme = themeStyle(props.themeId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@ -405,6 +405,7 @@ const SideMenuContentComponent = (props: Props) => {
paddingRight: theme.marginRight, paddingRight: theme.marginRight,
paddingLeft: 10, paddingLeft: 10,
}; };
const selected = isFolderSelected(folder, { selectedFolderId: props.selectedFolderId, notesParentType: props.notesParentType });
if (selected) folderButtonStyle.backgroundColor = theme.selectedColor; if (selected) folderButtonStyle.backgroundColor = theme.selectedColor;
folderButtonStyle.paddingLeft = depth * 10 + theme.marginLeft; folderButtonStyle.paddingLeft = depth * 10 + theme.marginLeft;

View File

@ -1,9 +1,9 @@
import { FolderEntity } from '../../services/database/types'; import { FolderEntity } from '../../services/database/types';
import { getTrashFolder, getTrashFolderId } from '../../services/trash'; import { getTrashFolder, getTrashFolderId } from '../../services/trash';
import { RenderFolderItem, renderFolders } from './side-menu-shared'; import { renderFolders } from './side-menu-shared';
const renderItem: RenderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) => { const renderItem = (folder: FolderEntity, hasChildren: boolean, depth: number) => {
return [folder.id, selected, hasChildren, depth]; return [folder.id, hasChildren, depth];
}; };
describe('side-menu-shared', () => { describe('side-menu-shared', () => {
@ -49,9 +49,9 @@ describe('side-menu-shared', () => {
}, },
{ {
items: [ items: [
['1', false, true, 0], ['1', true, 0],
['3', false, false, 1], ['3', false, 1],
['2', true, false, 0], ['2', false, 0],
], ],
order: ['1', '3', '2'], order: ['1', '3', '2'],
}, },
@ -79,9 +79,9 @@ describe('side-menu-shared', () => {
}, },
{ {
items: [ items: [
['1', false, false, 0], ['1', false, 0],
[getTrashFolderId(), false, true, 0], [getTrashFolderId(), true, 0],
['2', false, false, 1], ['2', false, 1],
], ],
order: ['1', getTrashFolderId(), '2'], order: ['1', getTrashFolderId(), '2'],
}, },

View File

@ -1,22 +1,11 @@
import Folder from '../../models/Folder'; import Folder from '../../models/Folder';
import BaseModel from '../../BaseModel'; import BaseModel from '../../BaseModel';
import { FolderEntity, TagEntity } from '../../services/database/types'; import { FolderEntity, TagEntity, TagsWithNoteCountEntity } from '../../services/database/types';
import { getDisplayParentId, getTrashFolderId } from '../../services/trash'; import { getDisplayParentId, getTrashFolderId } from '../../services/trash';
import { getCollator } from '../../models/utils/getCollator'; import { getCollator } from '../../models/utils/getCollator';
interface Props { export type RenderFolderItem<T> = (folder: FolderEntity, hasChildren: boolean, depth: number)=> T;
folders: FolderEntity[]; export type RenderTagItem<T> = (tag: TagsWithNoteCountEntity)=> T;
selectedFolderId: string;
notesParentType: string;
collapsedFolderIds: string[];
selectedTagId: string;
tags?: TagEntity[];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export type RenderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number)=> any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export type RenderTagItem = (tag: TagEntity, selected: boolean)=> any;
function folderHasChildren_(folders: FolderEntity[], folderId: string) { function folderHasChildren_(folders: FolderEntity[], folderId: string) {
if (folderId === getTrashFolderId()) { if (folderId === getTrashFolderId()) {
@ -45,8 +34,26 @@ function folderIsCollapsed(folders: FolderEntity[], folderId: string, collapsedF
} }
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied interface FolderSelectedContext {
function renderFoldersRecursive_(props: Props, renderItem: RenderFolderItem, items: any[], parentId: string, depth: number, order: string[]) { selectedFolderId: string;
notesParentType: string;
}
export const isFolderSelected = (folder: FolderEntity, context: FolderSelectedContext) => {
return context.selectedFolderId === folder.id && context.notesParentType === 'Folder';
};
type ItemsWithOrder<ItemType> = {
items: ItemType[];
order: string[];
};
interface RenderFoldersProps {
folders: FolderEntity[];
collapsedFolderIds: string[];
}
function renderFoldersRecursive_<T>(props: RenderFoldersProps, renderItem: RenderFolderItem<T>, items: T[], parentId: string, depth: number, order: string[]): ItemsWithOrder<T> {
const folders = props.folders; const folders = props.folders;
for (let i = 0; i < folders.length; i++) { for (let i = 0; i < folders.length; i++) {
const folder = folders[i]; const folder = folders[i];
@ -57,7 +64,7 @@ function renderFoldersRecursive_(props: Props, renderItem: RenderFolderItem, ite
if (folderIsCollapsed(props.folders, folder.id, props.collapsedFolderIds)) continue; if (folderIsCollapsed(props.folders, folder.id, props.collapsedFolderIds)) continue;
const hasChildren = folderHasChildren_(folders, folder.id); const hasChildren = folderHasChildren_(folders, folder.id);
order.push(folder.id); order.push(folder.id);
items.push(renderItem(folder, props.selectedFolderId === folder.id && props.notesParentType === 'Folder', hasChildren, depth)); items.push(renderItem(folder, hasChildren, depth));
if (hasChildren) { if (hasChildren) {
const result = renderFoldersRecursive_(props, renderItem, items, folder.id, depth + 1, order); const result = renderFoldersRecursive_(props, renderItem, items, folder.id, depth + 1, order);
items = result.items; items = result.items;
@ -70,12 +77,12 @@ function renderFoldersRecursive_(props: Props, renderItem: RenderFolderItem, ite
}; };
} }
export const renderFolders = (props: Props, renderItem: RenderFolderItem) => { export const renderFolders = <T> (props: RenderFoldersProps, renderItem: RenderFolderItem<T>): ItemsWithOrder<T> => {
return renderFoldersRecursive_(props, renderItem, [], '', 0, []); return renderFoldersRecursive_(props, renderItem, [], '', 0, []);
}; };
export const renderTags = (props: Props, renderItem: RenderTagItem) => { const sortTags = (tags: TagEntity[]) => {
const tags = props.tags.slice(); tags = tags.slice();
const collator = getCollator(); const collator = getCollator();
tags.sort((a, b) => { tags.sort((a, b) => {
// It seems title can sometimes be undefined (perhaps when syncing // It seems title can sometimes be undefined (perhaps when syncing
@ -90,12 +97,25 @@ export const renderTags = (props: Props, renderItem: RenderTagItem) => {
// sort. // sort.
return collator.compare(a.title, b.title); return collator.compare(a.title, b.title);
}); });
return tags;
};
interface TagSelectedContext {
selectedTagId: string;
notesParentType: string;
}
export const isTagSelected = (tag: TagEntity, context: TagSelectedContext) => {
return context.selectedTagId === tag.id && context.notesParentType === 'Tag';
};
export const renderTags = <T> (unsortedTags: TagsWithNoteCountEntity[], renderItem: RenderTagItem<T>): ItemsWithOrder<T> => {
const tags = sortTags(unsortedTags);
const tagItems = []; const tagItems = [];
const order: string[] = []; const order: string[] = [];
for (let i = 0; i < tags.length; i++) { for (let i = 0; i < tags.length; i++) {
const tag = tags[i]; const tag = tags[i];
order.push(tag.id); order.push(tag.id);
tagItems.push(renderItem(tag, props.selectedTagId === tag.id && props.notesParentType === 'Tag')); tagItems.push(renderItem(tag));
} }
return { return {
items: tagItems, items: tagItems,

View File

@ -262,7 +262,6 @@ tkwidgets
tldr tldr
TLDR TLDR
Todos Todos
toggleblock
togglebutton togglebutton
togglemenuitem togglemenuitem
Tolu Tolu