Просмотр исходного кода

feat: use <FixedSizeList /> instead of react-vtree

Fela Maslen 5 лет назад
Родитель
Сommit
04e704df9c
3 измененных файлов с 115 добавлено и 132 удалено
  1. 3 1
      gmus/package.json
  2. 93 118
      gmus/src/components/ui/cmus/views/artists.tsx
  3. 19 13
      gmus/yarn.lock

+ 3 - 1
gmus/package.json

@@ -64,6 +64,8 @@
     "@types/pluralize": "^0.0.29",
     "@types/react": "^16.9.53",
     "@types/react-dom": "^16.9.8",
+    "@types/react-virtualized-auto-sizer": "^1.0.0",
+    "@types/react-window": "^1.8.2",
     "@types/styled-components": "^5.1.5",
     "axios": "^0.21.0",
     "eslint-config-airbnb-base": "^14.2.1",
@@ -83,7 +85,7 @@
     "react-scripts": "4.0.1",
     "react-spinners": "^0.9.0",
     "react-storage-hooks": "^4.0.1",
-    "react-vtree": "^3.0.0-beta.0",
+    "react-virtualized-auto-sizer": "^1.0.2",
     "react-window": "^1.8.6",
     "styled-components": "^5.2.1",
     "styled-reset": "^4.3.3",

+ 93 - 118
gmus/src/components/ui/cmus/views/artists.tsx

@@ -8,15 +8,15 @@ import React, {
   useRef,
   useState,
 } from 'react';
-import { FixedSizeNodeData, FixedSizeTree as Tree, TreeWalker, TreeWalkerValue } from 'react-vtree';
-import { NodeComponentProps, NodePublicState } from 'react-vtree/dist/es/Tree';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import { FixedSizeList as List } from 'react-window';
 
 import { useArtistsAlbumsAndSongs } from '../../../../hooks/fetch/artists';
+import { namedMemo } from '../../../../utils/component';
 import { artistAlbumsLoaded, artistSongsLoaded } from '../actions';
 import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
 import { NoWrapFill } from '../styled/layout';
 import { AsciiSpinner } from '../styled/spinner';
-import { CmusUIState } from '../types';
 import { getScrollIndex } from '../utils/scroll';
 
 import * as Styled from './artists.styles';
@@ -25,126 +25,68 @@ type Props = {
   active: boolean;
 };
 
-type TreeNode = {
-  name: string;
+type ArtistData = {
   id: string;
-  focused: boolean;
+  artist: string;
+  expanded: boolean;
+  loading: boolean;
   active: boolean;
-  loading?: boolean;
-  shouldBeOpen?: boolean;
-  children?: TreeNode[];
+  parentActive: boolean;
 };
 
-type TreeMeta = {
-  node: TreeNode;
+type AlbumData = {
+  id: string;
+  album: string;
+  active: boolean;
+  parentActive: boolean;
 };
 
-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 && activeAlbum === null,
-        shouldBeOpen: expandedArtists.includes(artist),
-        loading: !(artist in artistAlbums),
-        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,
-        loading: node.loading,
-        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],
-  );
+type RowData = ArtistData | AlbumData;
 
-  return { treeWalker, haveData: treeNodes.length > 0 };
-}
+const isArtist = (data: RowData): data is ArtistData => Reflect.has(data, 'artist');
 
-const Node: React.FC<NodeComponentProps<TreeData, NodePublicState<TreeData>>> = ({
-  data: { name, isArtist, focused, active, shouldBeOpen, loading },
-  isOpen,
-  setOpen,
-  style,
-}) => {
-  useEffect(() => {
-    if (!!isOpen !== !!shouldBeOpen) {
-      setOpen(!!shouldBeOpen);
-    }
-  }, [isOpen, shouldBeOpen, setOpen]);
+const itemKey = (index: number, data: RowData[]): string => data[index].id;
 
-  if (isArtist) {
-    return (
-      <Styled.ArtistTitle active={active} parentActive={focused} style={style as CSSProperties}>
-        {loading && shouldBeOpen ? <AsciiSpinner /> : <>&nbsp;&nbsp;</>}
-        <NoWrapFill>{name || 'Unknown Artist'}</NoWrapFill>
-      </Styled.ArtistTitle>
-    );
-  }
+const Artist = namedMemo<{ row: ArtistData; style: CSSProperties }>(
+  'Artist',
+  ({ row: { artist, expanded, loading, active, parentActive }, style }) => (
+    <Styled.ArtistTitle active={active} parentActive={parentActive} style={style}>
+      {loading && expanded ? <AsciiSpinner /> : <>&nbsp;&nbsp;</>}
+      <NoWrapFill>{artist || 'Unknown Artist'}</NoWrapFill>
+    </Styled.ArtistTitle>
+  ),
+);
 
-  return (
-    <Styled.AlbumTitle active={active} parentActive={focused} style={style as CSSProperties}>
-      <NoWrapFill>{name || 'Unknown Album'}</NoWrapFill>
+const Album = namedMemo<{ row: AlbumData; style: CSSProperties }>(
+  'Album',
+  ({ row: { album, active, parentActive }, style }) => (
+    <Styled.AlbumTitle active={active} parentActive={parentActive} style={style}>
+      <NoWrapFill>{album || 'Unknown Album'}</NoWrapFill>
     </Styled.AlbumTitle>
-  );
-};
+  ),
+);
+
+const Row = namedMemo<{ index: number; data: RowData[]; style: CSSProperties }>(
+  'ArtistListRow',
+  ({ index, data, style }) => {
+    const row = data[index];
+    if (isArtist(row)) {
+      return <Artist row={row} style={style} />;
+    }
+    return <Album row={row} style={style} />;
+  },
+);
 
 const lineHeight = 16;
 const scrollThresholdLines = 4;
 
-export const Artists: React.FC<Props> = ({ active }) => {
+export const Artists: React.FC<Props> = ({ active: parentActive }) => {
   const dispatchUI = useContext(CmusUIDispatchContext);
   const state = useContext(CmusUIStateContext);
   const {
-    library: { activeArtist, expandedArtists },
+    artists,
+    artistAlbums,
+    library: { activeArtist, activeAlbum, expandedArtists },
   } = state;
 
   const [debouncedActiveArtist, setDebouncedActiveArtist] = useDebounce(activeArtist, 100);
@@ -168,6 +110,37 @@ export const Artists: React.FC<Props> = ({ active }) => {
     }
   }, [dispatchUI, songs]);
 
+  const itemData = useMemo<RowData[]>(
+    () =>
+      artists.reduce<RowData[]>((last, artist) => {
+        const expanded = expandedArtists.includes(artist);
+        const artistRow: ArtistData = {
+          id: artist,
+          artist,
+          expanded: expandedArtists.includes(artist),
+          loading: !(artist in artistAlbums),
+          active: activeArtist === artist && activeAlbum === null,
+          parentActive: parentActive && activeArtist === artist,
+        };
+
+        if (!expanded) {
+          return [...last, artistRow];
+        }
+
+        return [
+          ...last,
+          artistRow,
+          ...(artistAlbums[artist] ?? []).map<AlbumData>((album) => ({
+            id: `${artist}-${album}`,
+            album,
+            active: activeArtist === artist && activeAlbum === album,
+            parentActive: parentActive && activeArtist === artist && activeAlbum === album,
+          })),
+        ];
+      }, []),
+    [parentActive, artists, artistAlbums, activeArtist, activeAlbum, expandedArtists],
+  );
+
   const ref = useRef<HTMLDivElement>(null);
   const [windowDimensions, setWindowDimensions] = useState<{ width: number; height: number }>({
     width: 0,
@@ -219,21 +192,23 @@ export const Artists: React.FC<Props> = ({ active }) => {
     }
   }, [windowDimensions.height, scrollIndex]);
 
-  const { treeWalker, haveData } = useTreeWalker(state, active);
-
   return (
     <Styled.Container ref={ref}>
-      {haveData && (
-        <Tree
-          outerRef={windowRef}
-          treeWalker={treeWalker}
-          itemSize={lineHeight}
-          width={windowDimensions.width}
-          height={windowDimensions.height}
-        >
-          {Node}
-        </Tree>
-      )}
+      <AutoSizer>
+        {({ height, width }): React.ReactElement => (
+          <List
+            outerRef={windowRef}
+            height={height}
+            width={width}
+            itemCount={artists.length}
+            itemSize={lineHeight}
+            itemKey={itemKey}
+            itemData={itemData}
+          >
+            {Row}
+          </List>
+        )}
+      </AutoSizer>
     </Styled.Container>
   );
 };

+ 19 - 13
gmus/yarn.lock

@@ -1106,7 +1106,7 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@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":
+"@babel/runtime@^7.0.0", "@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":
   version "7.12.5"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e"
   integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==
@@ -1959,6 +1959,20 @@
   dependencies:
     "@types/react" "*"
 
+"@types/react-virtualized-auto-sizer@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.0.tgz#fc32f30a8dab527b5816f3a757e1e1d040c8f272"
+  integrity sha512-NMErdIdSnm2j/7IqMteRiRvRulpjoELnXWUwdbucYCz84xG9PHcoOrr7QfXwB/ku7wd6egiKFrzt/+QK4Imeeg==
+  dependencies:
+    "@types/react" "*"
+
+"@types/react-window@^1.8.2":
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.2.tgz#a5a6b2762ce73ffaab7911ee1397cf645f2459fe"
+  integrity sha512-gP1xam68Wc4ZTAee++zx6pTdDAH08rAkQrWm4B4F/y6hhmlT9Mgx2q8lTCXnrPHXsr15XjRN9+K2DLKcz44qEQ==
+  dependencies:
+    "@types/react" "*"
+
 "@types/react@*":
   version "17.0.0"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.0.tgz#5af3eb7fad2807092f0046a1302b7823e27919b8"
@@ -9465,11 +9479,6 @@ 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"
@@ -9553,13 +9562,10 @@ 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-virtualized-auto-sizer@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd"
+  integrity sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg==
 
 react-window@^1.8.6:
   version "1.8.6"