1
0
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:
Laurent Cozic
2025-03-02 22:20:47 +00:00
committed by GitHub
parent 0cc0fec8c3
commit 06359834d6
13 changed files with 144 additions and 9 deletions

View File

@ -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
View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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';

View File

@ -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;
}
}

View File

@ -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,
];

View 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) : [],
});
},
};
};

View 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);
});
});

View 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);
};

View 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;
};

View File

@ -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);

View File

@ -171,3 +171,5 @@ millis
sideloading
ggml
Minidump
collapseall
newfolder