diff --git a/.eslintignore b/.eslintignore index 240a7bd184..2d70731d42 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/.gitignore b/.gitignore index e67e37a2c0..27d9bcd48e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/app-desktop/gui/Sidebar/FolderAndTagList.tsx b/packages/app-desktop/gui/Sidebar/FolderAndTagList.tsx index 5de96ca0da..1499c50867 100644 --- a/packages/app-desktop/gui/Sidebar/FolderAndTagList.tsx +++ b/packages/app-desktop/gui/Sidebar/FolderAndTagList.tsx @@ -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 => { listItems: listItems, }); + const allFoldersCollapsed = useMemo(() => { + return areAllFoldersCollapsed(props.folders, props.collapsedFolderIds); + }, [props.collapsedFolderIds, props.folders]); + const listContainerRef = useRef(null); const onRenderItem = useOnRenderItem({ ...props, @@ -67,7 +72,7 @@ const FolderAndTagList: React.FC = 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 (
{ 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 ; +}; + 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 ; }; -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 <> +
{...listItems}
; - }, [selectedIndex, onKeyDown]); + }, [props.selectedIndex, props.onKeyDown, props.allFoldersCollapsed]); }; export default useOnRenderListWrapper; diff --git a/packages/app-desktop/gui/Sidebar/style.scss b/packages/app-desktop/gui/Sidebar/style.scss index f1598f1889..206281f564 100644 --- a/packages/app-desktop/gui/Sidebar/style.scss +++ b/packages/app-desktop/gui/Sidebar/style.scss @@ -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'; \ No newline at end of file +@use 'styles/sidebar-header-button.scss'; \ No newline at end of file diff --git a/packages/app-desktop/gui/Sidebar/styles/new-folder-button.scss b/packages/app-desktop/gui/Sidebar/styles/sidebar-header-button.scss similarity index 81% rename from packages/app-desktop/gui/Sidebar/styles/new-folder-button.scss rename to packages/app-desktop/gui/Sidebar/styles/sidebar-header-button.scss index 2bfcb379de..90f851e717 100644 --- a/packages/app-desktop/gui/Sidebar/styles/new-folder-button.scss +++ b/packages/app-desktop/gui/Sidebar/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; + } } diff --git a/packages/lib/commands/index.ts b/packages/lib/commands/index.ts index f3bef142e7..38403bb83c 100644 --- a/packages/lib/commands/index.ts +++ b/packages/lib/commands/index.ts @@ -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, ]; diff --git a/packages/lib/commands/toggleAllFolders.ts b/packages/lib/commands/toggleAllFolders.ts new file mode 100644 index 0000000000..eabe50f287 --- /dev/null +++ b/packages/lib/commands/toggleAllFolders.ts @@ -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) : [], + }); + }, + }; +}; diff --git a/packages/lib/models/utils/areAllFoldersCollapsed.test.ts b/packages/lib/models/utils/areAllFoldersCollapsed.test.ts new file mode 100644 index 0000000000..204abe19a3 --- /dev/null +++ b/packages/lib/models/utils/areAllFoldersCollapsed.test.ts @@ -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); + }); + +}); diff --git a/packages/lib/models/utils/areAllFoldersCollapsed.ts b/packages/lib/models/utils/areAllFoldersCollapsed.ts new file mode 100644 index 0000000000..146d6cdc99 --- /dev/null +++ b/packages/lib/models/utils/areAllFoldersCollapsed.ts @@ -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); +}; diff --git a/packages/lib/models/utils/getCanBeCollapsedFolderIds.ts b/packages/lib/models/utils/getCanBeCollapsedFolderIds.ts new file mode 100644 index 0000000000..cfa4ea9688 --- /dev/null +++ b/packages/lib/models/utils/getCanBeCollapsedFolderIds.ts @@ -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; +}; diff --git a/packages/lib/reducer.ts b/packages/lib/reducer.ts index 1e972efa77..9c6c3c7216 100644 --- a/packages/lib/reducer.ts +++ b/packages/lib/reducer.ts @@ -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, action: any) { + if (action.ids) { + draft.collapsedFolderIds = action.ids; + return; + } + const collapsedFolderIds = draft.collapsedFolderIds.slice(); const idx = collapsedFolderIds.indexOf(action.id); diff --git a/packages/tools/cspell/dictionary4.txt b/packages/tools/cspell/dictionary4.txt index 3b25d44dd4..49aaa01bf9 100644 --- a/packages/tools/cspell/dictionary4.txt +++ b/packages/tools/cspell/dictionary4.txt @@ -171,3 +171,5 @@ millis sideloading ggml Minidump +collapseall +newfolder \ No newline at end of file