You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-07-16 00:14:34 +02:00
This commit is contained in:
@ -396,10 +396,26 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.js
|
||||
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.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/commands/focusElementSideBar.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/types.js
|
||||
packages/app-desktop/gui/StatusScreen/StatusScreen.js
|
||||
packages/app-desktop/gui/StyleSheets/StyleSheetContainer.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/dialogs.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/usePrevious.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/NoteEditorScreen.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/util/activateMainMenuItem.js
|
||||
packages/app-desktop/integration-tests/util/createStartupArgs.js
|
||||
|
19
.gitignore
vendored
19
.gitignore
vendored
@ -376,10 +376,26 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.js
|
||||
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.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/commands/focusElementSideBar.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/types.js
|
||||
packages/app-desktop/gui/StatusScreen/StatusScreen.js
|
||||
packages/app-desktop/gui/StyleSheets/StyleSheetContainer.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/dialogs.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/usePrevious.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/NoteEditorScreen.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/util/activateMainMenuItem.js
|
||||
packages/app-desktop/integration-tests/util/createStartupArgs.js
|
||||
|
@ -22,6 +22,7 @@ interface Props {
|
||||
title?: string;
|
||||
iconName?: string;
|
||||
level?: ButtonLevel;
|
||||
iconLabel?: string;
|
||||
className?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
onClick?: Function;
|
||||
@ -219,7 +220,7 @@ const Button = React.forwardRef((props: Props, ref: any) => {
|
||||
|
||||
function renderIcon() {
|
||||
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() {
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
|
||||
import Dialog from '../Dialog';
|
||||
@ -127,13 +127,14 @@ export default function(props: Props) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const formTitleInputId = useId();
|
||||
function renderForm() {
|
||||
return (
|
||||
<div>
|
||||
<div className="form">
|
||||
<div className="form-input-group">
|
||||
<label>{_('Title')}</label>
|
||||
<StyledInput type="text" ref={titleInputRef} value={folderTitle} onChange={onFolderTitleChange}/>
|
||||
<label htmlFor={formTitleInputId}>{_('Title')}</label>
|
||||
<StyledInput id={formTitleInputId} type="text" ref={titleInputRef} value={folderTitle} onChange={onFolderTitleChange}/>
|
||||
</div>
|
||||
|
||||
<div className="form-input-group">
|
||||
|
@ -1,19 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { DragEventHandler, KeyboardEventHandler, UIEventHandler } from 'react';
|
||||
|
||||
interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
style: any;
|
||||
interface Props<ItemType> {
|
||||
style: React.CSSProperties & { height: number };
|
||||
itemHeight: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
items: any[];
|
||||
items: ItemType[];
|
||||
disabled?: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
onKeyDown?: Function;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
itemRenderer: Function;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLElement>;
|
||||
itemRenderer: (item: ItemType, index: number)=> React.JSX.Element;
|
||||
className?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
onNoteDrop?: Function;
|
||||
onItemDrop?: DragEventHandler<HTMLElement>;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@ -21,13 +17,12 @@ interface State {
|
||||
bottomItemIndex: number;
|
||||
}
|
||||
|
||||
class ItemList extends React.Component<Props, State> {
|
||||
class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
|
||||
|
||||
private scrollTop_: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private listRef: any;
|
||||
private listRef: React.MutableRefObject<HTMLDivElement>;
|
||||
|
||||
public constructor(props: Props) {
|
||||
public constructor(props: Props<ItemType>) {
|
||||
super(props);
|
||||
|
||||
this.scrollTop_ = 0;
|
||||
@ -39,12 +34,12 @@ class ItemList extends React.Component<Props, State> {
|
||||
this.onDrop = this.onDrop.bind(this);
|
||||
}
|
||||
|
||||
public visibleItemCount(props: Props = undefined) {
|
||||
public visibleItemCount(props: Props<ItemType> = undefined) {
|
||||
if (typeof props === 'undefined') props = this.props;
|
||||
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;
|
||||
|
||||
const topItemIndex = Math.floor(this.scrollTop_ / props.itemHeight);
|
||||
@ -67,35 +62,47 @@ class ItemList extends React.Component<Props, State> {
|
||||
return this.scrollTop_;
|
||||
}
|
||||
|
||||
public get container() {
|
||||
return this.listRef.current;
|
||||
}
|
||||
|
||||
public UNSAFE_componentWillMount() {
|
||||
this.updateStateItemIndexes();
|
||||
}
|
||||
|
||||
public UNSAFE_componentWillReceiveProps(newProps: Props) {
|
||||
public UNSAFE_componentWillReceiveProps(newProps: Props<ItemType>) {
|
||||
this.updateStateItemIndexes(newProps);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public onScroll(event: any) {
|
||||
this.scrollTop_ = event.target.scrollTop;
|
||||
public onScroll: UIEventHandler<HTMLDivElement> = event => {
|
||||
this.scrollTop_ = (event.target as HTMLElement).scrollTop;
|
||||
this.updateStateItemIndexes();
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public onKeyDown(event: any) {
|
||||
public onKeyDown: KeyboardEventHandler<HTMLElement> = 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 onDrop(event: any) {
|
||||
if (this.props.onNoteDrop) this.props.onNoteDrop(event);
|
||||
public get lastVisibleIndex() {
|
||||
return Math.max(0, this.state.bottomItemIndex);
|
||||
}
|
||||
|
||||
public isIndexVisible(itemIndex: number) {
|
||||
return itemIndex >= this.firstVisibleIndex && itemIndex <= this.lastVisibleIndex;
|
||||
}
|
||||
|
||||
public makeItemIndexVisible(itemIndex: number) {
|
||||
const top = Math.min(this.props.items.length - 1, this.state.topItemIndex);
|
||||
const bottom = Math.max(0, this.state.bottomItemIndex);
|
||||
if (this.isIndexVisible(itemIndex)) return;
|
||||
|
||||
if (itemIndex >= top && itemIndex <= bottom) return;
|
||||
const top = this.firstVisibleIndex;
|
||||
|
||||
let scrollTop = 0;
|
||||
if (itemIndex < top) {
|
||||
@ -130,8 +137,11 @@ class ItemList extends React.Component<Props, State> {
|
||||
|
||||
public render() {
|
||||
const items = this.props.items;
|
||||
const style = { ...this.props.style, overflowX: 'hidden',
|
||||
overflowY: 'auto' };
|
||||
const style: React.CSSProperties = {
|
||||
...this.props.style,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
};
|
||||
|
||||
// if (this.props.disabled) style.opacity = 0.5;
|
||||
|
||||
|
100
packages/app-desktop/gui/Sidebar/FolderAndTagList.tsx
Normal file
100
packages/app-desktop/gui/Sidebar/FolderAndTagList.tsx
Normal 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);
|
@ -1,765 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef, useCallback, useMemo, DragEventHandler, MouseEventHandler, RefObject } from 'react';
|
||||
import { StyledRoot, StyledAddButton, StyledShareIcon, StyledHeader, StyledHeaderIcon, StyledAllNotesIcon, StyledHeaderLabel, StyledListItem, StyledListItemAnchor, StyledExpandLink, StyledNoteCount, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton, StyledSpanFix } from './styles';
|
||||
import { StyledRoot, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton } from './styles';
|
||||
import { ButtonLevel } from '../Button/Button';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import InteropService from '@joplin/lib/services/interop/InteropService';
|
||||
import Synchronizer from '@joplin/lib/Synchronizer';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import InteropServiceHelper from '../../InteropServiceHelper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
|
||||
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import 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 { StateDecryptionWorker, StateResourceFetcher } from '@joplin/lib/reducer';
|
||||
import { connect } from 'react-redux';
|
||||
import { renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared';
|
||||
import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { ThemeStyle, themeStyle } from '@joplin/lib/theme';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Dispatch } from 'redux';
|
||||
import bridge from '../../services/bridge';
|
||||
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';
|
||||
import FolderAndTagList from './FolderAndTagList';
|
||||
|
||||
const logger = Logger.create('Sidebar');
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
dispatch: Dispatch;
|
||||
folders: FolderEntity[];
|
||||
collapsedFolderIds: string[];
|
||||
notesParentType: string;
|
||||
selectedFolderId: string;
|
||||
selectedTagId: string;
|
||||
selectedSmartFilterId: string;
|
||||
decryptionWorker: StateDecryptionWorker;
|
||||
resourceFetcher: StateResourceFetcher;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
syncReport: any;
|
||||
tags: TagEntity[];
|
||||
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 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 label = type === 'sync' ? _('Synchronise') : _('Cancel');
|
||||
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 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 = '';
|
||||
if (props.decryptionWorker && props.decryptionWorker.state !== 'idle' && props.decryptionWorker.itemCount) {
|
||||
decryptionReportText = _('Decrypting items: %d/%d', props.decryptionWorker.itemIndex + 1, props.decryptionWorker.itemCount);
|
||||
@ -864,8 +74,8 @@ const SidebarComponent = (props: Props) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledRoot ref={rootRef} onKeyDown={onKeyDown} className="sidebar">
|
||||
<div style={{ flex: 1, overflowX: 'hidden', overflowY: 'auto' }}>{items}</div>
|
||||
<StyledRoot className="sidebar">
|
||||
<div style={{ flex: 1 }}><FolderAndTagList/></div>
|
||||
<div style={{ flex: 0, padding: theme.mainPadding }}>
|
||||
{syncReportComp}
|
||||
{syncButton}
|
||||
@ -876,24 +86,16 @@ const SidebarComponent = (props: Props) => {
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
folders: state.folders,
|
||||
tags: state.tags,
|
||||
searches: state.searches,
|
||||
syncStarted: state.syncStarted,
|
||||
syncReport: state.syncReport,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
selectedTagId: state.selectedTagId,
|
||||
selectedSearchId: state.selectedSearchId,
|
||||
selectedSmartFilterId: state.selectedSmartFilterId,
|
||||
notesParentType: state.notesParentType,
|
||||
locale: state.settings.locale,
|
||||
themeId: state.settings.theme,
|
||||
collapsedFolderIds: state.collapsedFolderIds,
|
||||
decryptionWorker: state.decryptionWorker,
|
||||
resourceFetcher: state.resourceFetcher,
|
||||
plugins: state.pluginService.plugins,
|
||||
tagHeaderIsExpanded: state.settings.tagHeaderIsExpanded,
|
||||
folderHeaderIsExpanded: state.settings.folderHeaderIsExpanded,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -2,7 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import layoutItemProp from '../../ResizableLayout/utils/layoutItemProp';
|
||||
import { AppState } from '../../../app.reducer';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { SidebarCommandRuntimeProps } from '../types';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'focusElementSideBar',
|
||||
@ -10,29 +10,13 @@ export const declaration: CommandDeclaration = {
|
||||
parentLabel: () => _('Focus'),
|
||||
};
|
||||
|
||||
export interface RuntimeProps {
|
||||
// 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 => {
|
||||
export const runtime = (props: SidebarCommandRuntimeProps): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext) => {
|
||||
const sidebarVisible = layoutItemProp((context.state as AppState).mainLayout, 'sideBar', 'visible');
|
||||
|
||||
if (sidebarVisible) {
|
||||
const item = props.getSelectedItem();
|
||||
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);
|
||||
}
|
||||
props.focusSidebar();
|
||||
}
|
||||
},
|
||||
|
||||
|
82
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.ts
Normal file
82
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.ts
Normal 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;
|
434
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx
Normal file
434
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx
Normal 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;
|
@ -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;
|
@ -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;
|
@ -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;
|
98
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.ts
Normal file
98
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.ts
Normal 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;
|
@ -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);
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
5
packages/app-desktop/gui/Sidebar/style.scss
Normal file
5
packages/app-desktop/gui/Sidebar/style.scss
Normal 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';
|
@ -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;
|
||||
}
|
||||
}
|
@ -90,20 +90,6 @@ export const StyledShareIcon = styled.i`
|
||||
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`
|
||||
color: ${(props: StyleProps) => props.theme.colorFaded2};
|
||||
padding-left: 8px;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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%;
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
|
||||
.sidebar-header-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
|
||||
.sidebar-spacer-item {
|
||||
display: block;
|
||||
height: 30px;
|
||||
}
|
64
packages/app-desktop/gui/Sidebar/types.ts
Normal file
64
packages/app-desktop/gui/Sidebar/types.ts
Normal 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;
|
||||
}
|
23
packages/app-desktop/gui/hooks/useElementHeight.ts
Normal file
23
packages/app-desktop/gui/hooks/useElementHeight.ts
Normal 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;
|
@ -1,21 +1,25 @@
|
||||
import { Page, Locator, ElectronApplication } from '@playwright/test';
|
||||
import NoteEditorScreen from './NoteEditorScreen';
|
||||
import activateMainMenuItem from '../util/activateMainMenuItem';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
export default class MainScreen {
|
||||
public readonly newNoteButton: Locator;
|
||||
public readonly noteListContainer: Locator;
|
||||
public readonly sidebar: Sidebar;
|
||||
public readonly dialog: Locator;
|
||||
public readonly noteEditor: NoteEditorScreen;
|
||||
|
||||
public constructor(private page: Page) {
|
||||
this.newNoteButton = page.locator('.new-note-button');
|
||||
this.noteListContainer = page.locator('.rli-noteList');
|
||||
this.sidebar = new Sidebar(page, this);
|
||||
this.dialog = page.locator('.dialog-root');
|
||||
this.noteEditor = new NoteEditorScreen(page);
|
||||
}
|
||||
|
||||
public async waitFor() {
|
||||
await this.newNoteButton.waitFor();
|
||||
await this.noteEditor.waitFor();
|
||||
await this.noteListContainer.waitFor();
|
||||
}
|
||||
|
||||
|
46
packages/app-desktop/integration-tests/models/Sidebar.ts
Normal file
46
packages/app-desktop/integration-tests/models/Sidebar.ts
Normal 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);
|
||||
}
|
||||
}
|
104
packages/app-desktop/integration-tests/sidebar.spec.ts
Normal file
104
packages/app-desktop/integration-tests/sidebar.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
@ -6,17 +6,22 @@ import type { MenuItem } from 'electron';
|
||||
// Roughly based on
|
||||
// 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"]).
|
||||
const activateMainMenuItem = (electronApp: ElectronApplication, menuItemLabel: string) => {
|
||||
return electronApp.evaluate(async ({ Menu }, menuItemLabel) => {
|
||||
const activateItemInSubmenu = (submenu: MenuItem[]) => {
|
||||
// If given, `parentMenuLabel` should be the label of the menu containing the target item.
|
||||
const activateMainMenuItem = (
|
||||
electronApp: ElectronApplication,
|
||||
targetItemLabel: string,
|
||||
parentMenuLabel?: string,
|
||||
) => {
|
||||
return electronApp.evaluate(async ({ Menu }, [targetItemLabel, parentMenuLabel]) => {
|
||||
const activateItemInSubmenu = (submenu: MenuItem[], parentLabel: string) => {
|
||||
for (const item of submenu) {
|
||||
if (item.label === menuItemLabel && item.visible) {
|
||||
const matchesParent = !parentMenuLabel || parentLabel === parentMenuLabel;
|
||||
if (item.label === targetItemLabel && matchesParent && item.visible) {
|
||||
// Found!
|
||||
item.click();
|
||||
return true;
|
||||
} else if (item.submenu) {
|
||||
const foundItem = activateItemInSubmenu(item.submenu.items);
|
||||
const foundItem = activateItemInSubmenu(item.submenu.items, item.label);
|
||||
|
||||
if (foundItem) {
|
||||
return true;
|
||||
@ -29,8 +34,8 @@ const activateMainMenuItem = (electronApp: ElectronApplication, menuItemLabel: s
|
||||
};
|
||||
|
||||
const appMenu = Menu.getApplicationMenu();
|
||||
return activateItemInSubmenu(appMenu.items);
|
||||
}, menuItemLabel);
|
||||
return activateItemInSubmenu(appMenu.items, '');
|
||||
}, [targetItemLabel, parentMenuLabel]);
|
||||
};
|
||||
|
||||
export default activateMainMenuItem;
|
||||
|
@ -9,4 +9,5 @@
|
||||
@use 'gui/JoplinCloudLoginScreen.scss' as joplin-cloud-login-screen;
|
||||
@use 'gui/NoteListHeader/style.scss' as note-list-header;
|
||||
@use 'gui/TrashNotification/style.scss' as trash-notification;
|
||||
@use 'gui/Sidebar/style.scss' as sidebar-styles;
|
||||
@use 'main.scss' as main;
|
@ -8,7 +8,7 @@ import Synchronizer from '@joplin/lib/Synchronizer';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
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 { AppState } from '../utils/types';
|
||||
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);
|
||||
|
||||
// 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,
|
||||
paddingLeft: 10,
|
||||
};
|
||||
const selected = isFolderSelected(folder, { selectedFolderId: props.selectedFolderId, notesParentType: props.notesParentType });
|
||||
if (selected) folderButtonStyle.backgroundColor = theme.selectedColor;
|
||||
folderButtonStyle.paddingLeft = depth * 10 + theme.marginLeft;
|
||||
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { FolderEntity } from '../../services/database/types';
|
||||
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) => {
|
||||
return [folder.id, selected, hasChildren, depth];
|
||||
const renderItem = (folder: FolderEntity, hasChildren: boolean, depth: number) => {
|
||||
return [folder.id, hasChildren, depth];
|
||||
};
|
||||
|
||||
describe('side-menu-shared', () => {
|
||||
@ -49,9 +49,9 @@ describe('side-menu-shared', () => {
|
||||
},
|
||||
{
|
||||
items: [
|
||||
['1', false, true, 0],
|
||||
['3', false, false, 1],
|
||||
['2', true, false, 0],
|
||||
['1', true, 0],
|
||||
['3', false, 1],
|
||||
['2', false, 0],
|
||||
],
|
||||
order: ['1', '3', '2'],
|
||||
},
|
||||
@ -79,9 +79,9 @@ describe('side-menu-shared', () => {
|
||||
},
|
||||
{
|
||||
items: [
|
||||
['1', false, false, 0],
|
||||
[getTrashFolderId(), false, true, 0],
|
||||
['2', false, false, 1],
|
||||
['1', false, 0],
|
||||
[getTrashFolderId(), true, 0],
|
||||
['2', false, 1],
|
||||
],
|
||||
order: ['1', getTrashFolderId(), '2'],
|
||||
},
|
||||
|
@ -1,22 +1,11 @@
|
||||
import Folder from '../../models/Folder';
|
||||
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 { getCollator } from '../../models/utils/getCollator';
|
||||
|
||||
interface Props {
|
||||
folders: FolderEntity[];
|
||||
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;
|
||||
export type RenderFolderItem<T> = (folder: FolderEntity, hasChildren: boolean, depth: number)=> T;
|
||||
export type RenderTagItem<T> = (tag: TagsWithNoteCountEntity)=> T;
|
||||
|
||||
function folderHasChildren_(folders: FolderEntity[], folderId: string) {
|
||||
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
|
||||
function renderFoldersRecursive_(props: Props, renderItem: RenderFolderItem, items: any[], parentId: string, depth: number, order: string[]) {
|
||||
interface FolderSelectedContext {
|
||||
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;
|
||||
for (let i = 0; i < folders.length; 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;
|
||||
const hasChildren = folderHasChildren_(folders, 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) {
|
||||
const result = renderFoldersRecursive_(props, renderItem, items, folder.id, depth + 1, order);
|
||||
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, []);
|
||||
};
|
||||
|
||||
export const renderTags = (props: Props, renderItem: RenderTagItem) => {
|
||||
const tags = props.tags.slice();
|
||||
const sortTags = (tags: TagEntity[]) => {
|
||||
tags = tags.slice();
|
||||
const collator = getCollator();
|
||||
tags.sort((a, b) => {
|
||||
// It seems title can sometimes be undefined (perhaps when syncing
|
||||
@ -90,12 +97,25 @@ export const renderTags = (props: Props, renderItem: RenderTagItem) => {
|
||||
// sort.
|
||||
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 order: string[] = [];
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
const tag = tags[i];
|
||||
order.push(tag.id);
|
||||
tagItems.push(renderItem(tag, props.selectedTagId === tag.id && props.notesParentType === 'Tag'));
|
||||
tagItems.push(renderItem(tag));
|
||||
}
|
||||
return {
|
||||
items: tagItems,
|
||||
|
@ -262,7 +262,6 @@ tkwidgets
|
||||
tldr
|
||||
TLDR
|
||||
Todos
|
||||
toggleblock
|
||||
togglebutton
|
||||
togglemenuitem
|
||||
Tolu
|
||||
|
Reference in New Issue
Block a user