You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-07-13 00:10:37 +02:00
Desktop: Add a button to collapse or expand all folders (#11905)
This commit is contained in:
@ -1026,6 +1026,7 @@ packages/lib/commands/renderMarkup.test.js
|
||||
packages/lib/commands/renderMarkup.js
|
||||
packages/lib/commands/showEditorPlugin.js
|
||||
packages/lib/commands/synchronize.js
|
||||
packages/lib/commands/toggleAllFolders.js
|
||||
packages/lib/commands/toggleEditorPlugin.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.test.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
@ -1120,6 +1121,9 @@ packages/lib/models/settings/builtInMetadata.js
|
||||
packages/lib/models/settings/settingValidations.test.js
|
||||
packages/lib/models/settings/settingValidations.js
|
||||
packages/lib/models/settings/types.js
|
||||
packages/lib/models/utils/areAllFoldersCollapsed.test.js
|
||||
packages/lib/models/utils/areAllFoldersCollapsed.js
|
||||
packages/lib/models/utils/getCanBeCollapsedFolderIds.js
|
||||
packages/lib/models/utils/getCollator.js
|
||||
packages/lib/models/utils/getConflictFolderId.js
|
||||
packages/lib/models/utils/isItemId.js
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -1001,6 +1001,7 @@ packages/lib/commands/renderMarkup.test.js
|
||||
packages/lib/commands/renderMarkup.js
|
||||
packages/lib/commands/showEditorPlugin.js
|
||||
packages/lib/commands/synchronize.js
|
||||
packages/lib/commands/toggleAllFolders.js
|
||||
packages/lib/commands/toggleEditorPlugin.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.test.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
@ -1095,6 +1096,9 @@ packages/lib/models/settings/builtInMetadata.js
|
||||
packages/lib/models/settings/settingValidations.test.js
|
||||
packages/lib/models/settings/settingValidations.js
|
||||
packages/lib/models/settings/types.js
|
||||
packages/lib/models/utils/areAllFoldersCollapsed.test.js
|
||||
packages/lib/models/utils/areAllFoldersCollapsed.js
|
||||
packages/lib/models/utils/getCanBeCollapsedFolderIds.js
|
||||
packages/lib/models/utils/getCollator.js
|
||||
packages/lib/models/utils/getConflictFolderId.js
|
||||
packages/lib/models/utils/isItemId.js
|
||||
|
@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
|
||||
import areAllFoldersCollapsed from '@joplin/lib/models/utils/areAllFoldersCollapsed';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { connect } from 'react-redux';
|
||||
import { Dispatch } from 'redux';
|
||||
@ -41,6 +42,10 @@ const FolderAndTagList: React.FC<Props> = props => {
|
||||
listItems: listItems,
|
||||
});
|
||||
|
||||
const allFoldersCollapsed = useMemo(() => {
|
||||
return areAllFoldersCollapsed(props.folders, props.collapsedFolderIds);
|
||||
}, [props.collapsedFolderIds, props.folders]);
|
||||
|
||||
const listContainerRef = useRef<HTMLDivElement|null>(null);
|
||||
const onRenderItem = useOnRenderItem({
|
||||
...props,
|
||||
@ -67,7 +72,7 @@ const FolderAndTagList: React.FC<Props> = props => {
|
||||
const listHeight = useElementHeight(itemListContainer);
|
||||
const listStyle = useMemo(() => ({ height: listHeight }), [listHeight]);
|
||||
|
||||
const onRenderContentWrapper = useOnRenderListWrapper({ selectedIndex, onKeyDown: onKeyEventHandler });
|
||||
const onRenderContentWrapper = useOnRenderListWrapper({ allFoldersCollapsed, selectedIndex, onKeyDown: onKeyEventHandler });
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -6,16 +6,39 @@ import CommandService from '@joplin/lib/services/CommandService';
|
||||
interface Props {
|
||||
selectedIndex: number;
|
||||
onKeyDown: React.KeyboardEventHandler;
|
||||
allFoldersCollapsed: boolean;
|
||||
}
|
||||
|
||||
const onAddFolderButtonClick = () => {
|
||||
void CommandService.instance().execute('newFolder');
|
||||
};
|
||||
|
||||
const onToggleAllFolders = (allFoldersCollapsed: boolean) => {
|
||||
void CommandService.instance().execute('toggleAllFolders', !allFoldersCollapsed);
|
||||
};
|
||||
|
||||
interface CollapseExpandAllButtonProps {
|
||||
allFoldersCollapsed: boolean;
|
||||
}
|
||||
|
||||
const CollapseExpandAllButton = (props: CollapseExpandAllButtonProps) => {
|
||||
// To allow it to be accessed by accessibility tools, the new folder button
|
||||
// is not included in the portion of the list with role='tree'.
|
||||
const icon = props.allFoldersCollapsed ? 'far fa-caret-square-right' : 'far fa-caret-square-down';
|
||||
|
||||
return <button onClick={() => onToggleAllFolders(props.allFoldersCollapsed)} className='sidebar-header-button -collapseall'>
|
||||
<i
|
||||
aria-label={_('Collapse / Expand all notebooks')}
|
||||
role='img'
|
||||
className={icon}
|
||||
/>
|
||||
</button>;
|
||||
};
|
||||
|
||||
const NewFolderButton = () => {
|
||||
// To allow it to be accessed by accessibility tools, the new folder button
|
||||
// is not included in the portion of the list with role='tree'.
|
||||
return <button onClick={onAddFolderButtonClick} className='new-folder-button'>
|
||||
return <button onClick={onAddFolderButtonClick} className='sidebar-header-button -newfolder'>
|
||||
<i
|
||||
aria-label={_('New notebook')}
|
||||
role='img'
|
||||
@ -24,22 +47,23 @@ const NewFolderButton = () => {
|
||||
</button>;
|
||||
};
|
||||
|
||||
const useOnRenderListWrapper = ({ selectedIndex, onKeyDown }: Props) => {
|
||||
const useOnRenderListWrapper = (props: Props) => {
|
||||
return useCallback((listItems: React.ReactNode[]) => {
|
||||
const listHasValidSelection = selectedIndex >= 0;
|
||||
const listHasValidSelection = props.selectedIndex >= 0;
|
||||
const allowContainerFocus = !listHasValidSelection;
|
||||
return <>
|
||||
<CollapseExpandAllButton allFoldersCollapsed={props.allFoldersCollapsed}/>
|
||||
<NewFolderButton/>
|
||||
<div
|
||||
role='tree'
|
||||
className='sidebar-list-items-wrapper'
|
||||
tabIndex={allowContainerFocus ? 0 : undefined}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyDown={props.onKeyDown}
|
||||
>
|
||||
{...listItems}
|
||||
</div>
|
||||
</>;
|
||||
}, [selectedIndex, onKeyDown]);
|
||||
}, [props.selectedIndex, props.onKeyDown, props.allFoldersCollapsed]);
|
||||
};
|
||||
|
||||
export default useOnRenderListWrapper;
|
||||
|
@ -5,4 +5,4 @@
|
||||
@use 'styles/sidebar-expand-link.scss';
|
||||
@use 'styles/sidebar-header-container.scss';
|
||||
@use 'styles/sidebar-spacer-item.scss';
|
||||
@use 'styles/new-folder-button.scss';
|
||||
@use 'styles/sidebar-header-button.scss';
|
@ -1,11 +1,11 @@
|
||||
|
||||
.new-folder-button {
|
||||
.sidebar-header-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-end: 0;
|
||||
|
||||
padding-inline-end: 15px;
|
||||
padding-top: 4px;
|
||||
padding-top: 8px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
|
||||
@ -22,4 +22,8 @@
|
||||
color: var(--joplin-color-active2);
|
||||
background: none;
|
||||
}
|
||||
|
||||
&.-collapseall {
|
||||
right: 25px;
|
||||
}
|
||||
}
|
@ -7,6 +7,7 @@ import * as permanentlyDeleteNote from './permanentlyDeleteNote';
|
||||
import * as renderMarkup from './renderMarkup';
|
||||
import * as showEditorPlugin from './showEditorPlugin';
|
||||
import * as synchronize from './synchronize';
|
||||
import * as toggleAllFolders from './toggleAllFolders';
|
||||
import * as toggleEditorPlugin from './toggleEditorPlugin';
|
||||
|
||||
const index: any[] = [
|
||||
@ -18,6 +19,7 @@ const index: any[] = [
|
||||
renderMarkup,
|
||||
showEditorPlugin,
|
||||
synchronize,
|
||||
toggleAllFolders,
|
||||
toggleEditorPlugin,
|
||||
];
|
||||
|
||||
|
19
packages/lib/commands/toggleAllFolders.ts
Normal file
19
packages/lib/commands/toggleAllFolders.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '../services/CommandService';
|
||||
import { _ } from '../locale';
|
||||
import getCanBeCollapsedFolderIds from '../models/utils/getCanBeCollapsedFolderIds';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'toggleAllFolders',
|
||||
label: () => _('Toggle all notebooks'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, collapseAll: boolean) => {
|
||||
context.dispatch({
|
||||
type: 'FOLDER_SET_COLLAPSED',
|
||||
ids: collapseAll ? getCanBeCollapsedFolderIds(context.state.folders) : [],
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
35
packages/lib/models/utils/areAllFoldersCollapsed.test.ts
Normal file
35
packages/lib/models/utils/areAllFoldersCollapsed.test.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
||||
import Folder from '../Folder';
|
||||
import areAllFoldersCollapsed from './areAllFoldersCollapsed';
|
||||
|
||||
describe('areAllFoldersCollapsed', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
});
|
||||
|
||||
it('should tell if all folders are collapsed', async () => {
|
||||
const folder1 = await Folder.save({});
|
||||
await Folder.save({ parent_id: folder1.id });
|
||||
await Folder.save({ parent_id: folder1.id });
|
||||
|
||||
const folder2 = await Folder.save({ });
|
||||
const folder2a = await Folder.save({ parent_id: folder2.id });
|
||||
await Folder.save({ parent_id: folder2a.id });
|
||||
|
||||
expect(areAllFoldersCollapsed(await Folder.all(), [])).toBe(false);
|
||||
|
||||
expect(areAllFoldersCollapsed(await Folder.all(), [
|
||||
folder1.id,
|
||||
folder2.id,
|
||||
])).toBe(false);
|
||||
|
||||
expect(areAllFoldersCollapsed(await Folder.all(), [
|
||||
folder1.id,
|
||||
folder2.id,
|
||||
folder2a.id,
|
||||
])).toBe(true);
|
||||
});
|
||||
|
||||
});
|
10
packages/lib/models/utils/areAllFoldersCollapsed.ts
Normal file
10
packages/lib/models/utils/areAllFoldersCollapsed.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { FolderEntity } from '../../services/database/types';
|
||||
import getCanBeCollapsedFolderIds from './getCanBeCollapsedFolderIds';
|
||||
|
||||
export default (folders: FolderEntity[], collapsedFolderIds: string[]) => {
|
||||
const canBeCollapsedIds = getCanBeCollapsedFolderIds(folders);
|
||||
if (collapsedFolderIds.length !== canBeCollapsedIds.length) return false;
|
||||
collapsedFolderIds = collapsedFolderIds.slice().sort();
|
||||
canBeCollapsedIds.sort();
|
||||
return JSON.stringify(collapsedFolderIds) === JSON.stringify(canBeCollapsedIds);
|
||||
};
|
21
packages/lib/models/utils/getCanBeCollapsedFolderIds.ts
Normal file
21
packages/lib/models/utils/getCanBeCollapsedFolderIds.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { FolderEntity } from '../../services/database/types';
|
||||
import Folder, { FolderEntityWithChildren } from '../Folder';
|
||||
|
||||
export default (folders: FolderEntity[]) => {
|
||||
const tree = Folder.buildTree(folders);
|
||||
|
||||
const canBeCollapsedIds: string[] = [];
|
||||
|
||||
const processTree = (folders: FolderEntityWithChildren[]) => {
|
||||
for (const folder of folders) {
|
||||
if (folder.children.length) {
|
||||
canBeCollapsedIds.push(folder.id);
|
||||
processTree(folder.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processTree(tree);
|
||||
|
||||
return canBeCollapsedIds;
|
||||
};
|
@ -449,6 +449,11 @@ function stateHasEncryptedItems(state: State) {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function folderSetCollapsed(draft: Draft<State>, action: any) {
|
||||
if (action.ids) {
|
||||
draft.collapsedFolderIds = action.ids;
|
||||
return;
|
||||
}
|
||||
|
||||
const collapsedFolderIds = draft.collapsedFolderIds.slice();
|
||||
const idx = collapsedFolderIds.indexOf(action.id);
|
||||
|
||||
|
@ -171,3 +171,5 @@ millis
|
||||
sideloading
|
||||
ggml
|
||||
Minidump
|
||||
collapseall
|
||||
newfolder
|
Reference in New Issue
Block a user