Browse Source

feat: react-vtree implementation for artists list

Fela Maslen 5 năm trước cách đây
mục cha
commit
e0196d9617

+ 2 - 0
gmus/package.json

@@ -83,6 +83,8 @@
     "react-scripts": "4.0.1",
     "react-spinners": "^0.9.0",
     "react-storage-hooks": "^4.0.1",
+    "react-vtree": "^3.0.0-beta.0",
+    "react-window": "^1.8.6",
     "styled-components": "^5.2.1",
     "styled-reset": "^4.3.3",
     "typescript": "^4.0.3",

+ 5 - 9
gmus/src/components/ui/cmus/views/artists.styles.ts

@@ -1,20 +1,16 @@
 import { rem } from 'polished';
 import styled from 'styled-components';
 
-import { ActiveHighlightRow, FlexColumn, FlexList } from '../styled/layout';
+import { ActiveHighlightRow, FlexList } from '../styled/layout';
 
 export const Container = styled(FlexList)`
   flex: 1 0 0;
 `;
 
-export const ArtistRow = styled(FlexColumn)``;
-
 export const ArtistTitle = styled(ActiveHighlightRow)``;
 
-export const ArtistAlbums = styled.ul`
-  list-style: none;
-  margin: 0;
-  padding: 0 0 0 ${rem(16)};
+export const AlbumTitle = styled(ActiveHighlightRow)`
+  span {
+    padding-left: ${rem(16)};
+  }
 `;
-
-export const AlbumTitle = styled(ActiveHighlightRow)``;

+ 153 - 45
gmus/src/components/ui/cmus/views/artists.tsx

@@ -1,11 +1,20 @@
-import { useDebounce } from '@react-hook/debounce';
-import React, { useContext, useEffect } from 'react';
+import { useDebounce, useDebounceCallback } from '@react-hook/debounce';
+import React, {
+  CSSProperties,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import { FixedSizeNodeData, FixedSizeTree as Tree, TreeWalker, TreeWalkerValue } from 'react-vtree';
+import { NodeComponentProps, NodePublicState } from 'react-vtree/dist/es/Tree';
 
 import { useArtistsAlbumsAndSongs } from '../../../../hooks/fetch/artists';
-import { Song } from '../../../../types/songs';
-import { namedMemo } from '../../../../utils/component';
 import { artistAlbumsLoaded, artistSongsLoaded } from '../actions';
 import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
+import { CmusUIState } from '../types';
 
 import * as Styled from './artists.styles';
 
@@ -13,43 +22,120 @@ type Props = {
   active: boolean;
 };
 
-type PropsArtist = {
-  artist: string;
-  albums?: string[];
-  songs?: Song[];
+type TreeNode = {
+  name: string;
+  id: string;
+  focused: boolean;
   active: boolean;
-  parentActive: boolean;
-  expanded: boolean;
-  activeAlbum: string | null;
+  shouldBeOpen?: boolean;
+  children?: TreeNode[];
 };
 
-const Artist = namedMemo<PropsArtist>(
-  'Artist',
-  ({ artist, albums, active, parentActive, expanded, activeAlbum }) => (
-    <Styled.ArtistRow key={artist}>
-      <Styled.ArtistTitle active={active} parentActive={parentActive}>
-        <span>{artist || 'Unknown Artist'}</span>
+type TreeMeta = {
+  node: TreeNode;
+};
+
+type TreeData = FixedSizeNodeData &
+  Omit<TreeNode, 'children'> & {
+    isArtist: boolean;
+  };
+
+function useTreeWalker(
+  { artists, artistAlbums, library: { activeArtist, activeAlbum, expandedArtists } }: CmusUIState,
+  focused: boolean,
+): { treeWalker: TreeWalker<TreeData, TreeMeta>; haveData: boolean } {
+  const treeNodes = useMemo<TreeNode[]>(
+    () =>
+      artists.map<TreeNode>((artist) => ({
+        name: artist,
+        id: artist,
+        focused,
+        active: activeArtist === artist,
+        shouldBeOpen: expandedArtists.includes(artist),
+        children:
+          artistAlbums[artist]?.map<TreeNode>((album) => ({
+            name: album,
+            id: `${artist}-${album}`,
+            focused,
+            active: activeArtist === artist && activeAlbum === album,
+          })) ?? undefined,
+      })),
+    [artists, artistAlbums, focused, activeArtist, activeAlbum, expandedArtists],
+  );
+
+  const getNodeData = useCallback(
+    (node: TreeNode, isArtist: boolean): TreeWalkerValue<TreeData, TreeMeta> => ({
+      data: {
+        id: node.id,
+        name: node.name,
+        focused: node.focused,
+        active: node.active,
+        shouldBeOpen: node.shouldBeOpen,
+        isOpenByDefault: !!node.shouldBeOpen,
+        isArtist,
+      },
+      node,
+    }),
+    [],
+  );
+
+  const treeWalker = useMemo<TreeWalker<TreeData, TreeMeta>>(
+    () =>
+      // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
+      function* treeWalkerGenerator() {
+        for (let i = 0; i < treeNodes.length; i += 1) {
+          yield getNodeData(treeNodes[i], true);
+        }
+
+        while (true) {
+          const parent = yield;
+
+          if (parent?.node.children) {
+            for (let i = 0; i < parent.node.children.length; i += 1) {
+              yield getNodeData(parent.node.children[i], false);
+            }
+          }
+        }
+      },
+    [treeNodes, getNodeData],
+  );
+
+  return { treeWalker, haveData: treeNodes.length > 0 };
+}
+
+const Node: React.FC<NodeComponentProps<TreeData, NodePublicState<TreeData>>> = ({
+  data: { name, isArtist, focused, active, shouldBeOpen },
+  isOpen,
+  setOpen,
+  style,
+}) => {
+  useEffect(() => {
+    if (!!isOpen !== !!shouldBeOpen) {
+      setOpen(!!shouldBeOpen);
+    }
+  }, [isOpen, shouldBeOpen, setOpen]);
+
+  if (isArtist) {
+    return (
+      <Styled.ArtistTitle active={active} parentActive={focused} style={style as CSSProperties}>
+        <span>{name || 'Unknown Artist'}</span>
       </Styled.ArtistTitle>
-      {expanded && (
-        <Styled.ArtistAlbums>
-          {albums?.map((album) => (
-            <Styled.AlbumTitle key={album} active={active && album === activeAlbum}>
-              {album || 'Unknown Album'}
-            </Styled.AlbumTitle>
-          ))}
-        </Styled.ArtistAlbums>
-      )}
-    </Styled.ArtistRow>
-  ),
-);
+    );
+  }
+
+  return (
+    <Styled.AlbumTitle active={active} parentActive={focused} style={style as CSSProperties}>
+      <span>{name || 'Unknown Album'}</span>
+    </Styled.AlbumTitle>
+  );
+};
 
 export const Artists: React.FC<Props> = ({ active }) => {
   const dispatchUI = useContext(CmusUIDispatchContext);
+  const state = useContext(CmusUIStateContext);
   const {
-    artists,
-    artistAlbums,
-    library: { expandedArtists, activeArtist, activeAlbum },
-  } = useContext(CmusUIStateContext);
+    library: { activeArtist, expandedArtists },
+  } = state;
 
   const [debouncedActiveArtist, setDebouncedActiveArtist] = useDebounce(activeArtist, 100);
   useEffect(() => {
@@ -72,19 +158,41 @@ export const Artists: React.FC<Props> = ({ active }) => {
     }
   }, [dispatchUI, songs]);
 
+  const ref = useRef<HTMLDivElement>(null);
+  const [windowDimensions, setWindowDimensions] = useState<{ width: number; height: number }>({
+    width: 0,
+    height: 0,
+  });
+  const onResize = useCallback(() => {
+    setWindowDimensions({
+      width: ref.current?.offsetWidth ?? 0,
+      height: ref.current?.offsetHeight ?? 0,
+    });
+  }, []);
+  const resizeHandler = useDebounceCallback(onResize, 100);
+
+  useEffect(() => {
+    onResize();
+    window.addEventListener('resize', resizeHandler);
+    return (): void => {
+      window.removeEventListener('resize', resizeHandler);
+    };
+  }, [onResize, resizeHandler]);
+
+  const { treeWalker, haveData } = useTreeWalker(state, active);
+
   return (
-    <Styled.Container>
-      {artists.map((artist) => (
-        <Artist
-          key={artist}
-          artist={artist}
-          albums={artistAlbums[artist]}
-          active={artist === activeArtist}
-          parentActive={active}
-          expanded={expandedArtists.includes(artist)}
-          activeAlbum={activeAlbum}
-        />
-      ))}
+    <Styled.Container ref={ref}>
+      {haveData && (
+        <Tree
+          treeWalker={treeWalker}
+          itemSize={16}
+          width={windowDimensions.width}
+          height={windowDimensions.height}
+        >
+          {Node}
+        </Tree>
+      )}
     </Styled.Container>
   );
 };

+ 27 - 1
gmus/yarn.lock

@@ -1106,7 +1106,7 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
+"@babel/runtime@^7.0.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.0", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
   version "7.12.5"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e"
   integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==
@@ -7456,6 +7456,11 @@ media-typer@0.3.0:
   resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
   integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
 
+"memoize-one@>=3.1.1 <6":
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0"
+  integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA==
+
 memory-fs@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552"
@@ -9460,6 +9465,11 @@ react-is@^17.0.1:
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339"
   integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==
 
+react-merge-refs@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-1.1.0.tgz#73d88b892c6c68cbb7a66e0800faa374f4c38b06"
+  integrity sha512-alTKsjEL0dKH/ru1Iyn7vliS2QRcBp9zZPGoWxUOvRGWPUYgjo+V01is7p04It6KhgrzhJGnIj9GgX8W4bZoCQ==
+
 react-refresh@^0.8.3:
   version "0.8.3"
   resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
@@ -9543,6 +9553,22 @@ react-storage-hooks@^4.0.1:
   resolved "https://registry.yarnpkg.com/react-storage-hooks/-/react-storage-hooks-4.0.1.tgz#e30ed5cda48c77c431ecc02ec3824bd615f5b7fb"
   integrity sha512-fetDkT5RDHGruc2NrdD1iqqoLuXgbx6AUpQSQLLkrCiJf8i97EtwJNXNTy3+GRfsATLG8TZgNc9lGRZOaU5yQA==
 
+react-vtree@^3.0.0-beta.0:
+  version "3.0.0-beta.0"
+  resolved "https://registry.yarnpkg.com/react-vtree/-/react-vtree-3.0.0-beta.0.tgz#55425d367d7f66b11c375a080b770bc61ae88c9f"
+  integrity sha512-S7L19EPMkx7CNkqJqy34oACUoPJar0AkMhRTw54VbvJqcytVYJh1nI4W0rqFhbLfdBU1qJL6XPchKm7MMnZafw==
+  dependencies:
+    "@babel/runtime" "^7.11.0"
+    react-merge-refs "^1.1.0"
+
+react-window@^1.8.6:
+  version "1.8.6"
+  resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.6.tgz#d011950ac643a994118632665aad0c6382e2a112"
+  integrity sha512-8VwEEYyjz6DCnGBsd+MgkD0KJ2/OXFULyDtorIiTz+QzwoP94tBoA7CnbtyXMm+cCeAUER5KJcPtWl9cpKbOBg==
+  dependencies:
+    "@babel/runtime" "^7.0.0"
+    memoize-one ">=3.1.1 <6"
+
 react@^17.0.1:
   version "17.0.1"
   resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127"