diff --git a/README.md b/README.md index 0c29c83..41d506f 100755 --- a/README.md +++ b/README.md @@ -30,16 +30,17 @@ npm i exploration ## Features -- [x] Zero-recursion, expandable tree -- [x] Virtualization -- [x] Create/move/rename/delete -- [x] Drag and drop -- [x] Hotkeys -- [x] Multiselect -- [x] Traits (e.g. add class names to selections, focused elements, etc.) -- [x] Filtering/search -- [x] Strongly typed so you can engineer with confidence -- [x] Ready for React 18 concurrent mode +- [x] **Zero-recursion**, expandable tree +- [x] **Virtualization**, only render what is visible +- [x] **Create/delete/move/rename** actions +- [x] **Drag and drop** +- [x] **Hotkeys** +- [x] **Multiselect** +- [x] **Traits**, add class names to selections, focused elements, anything +- [x] **Filtering/search** +- [x] **Tree snapshot restoration** for persisting the expanded state of the tree between refreshes +- [x] **Strongly typed** so you can engineer with confidence +- [x] **Concurrent mode** safe, ready for React 18 --- @@ -48,9 +49,8 @@ npm i exploration File explorers in React tend to be both large, slow, and opinionated. They peter out at a hundred nodes and aren't suitable for building a complex file explorer in the browser. Other solutions like [Aspen](https://github.com/zikaari/aspen) aimed to solve this problem by -using typed arrays (which AFAICT don't offer much benefit) and event-driven models. They -did a pretty job, however, the documentation was sparse and the code was verbose as a result -of a well-designed API. Additionally, I found the library to be quite buggy. All that said, +using typed arrays (which don't seem to offer much benefit in performance) and event-driven models. +They did a pretty job, however, the documentation was sparse and the code was verbose. All that said, Aspen and Monaco's tree were huge inspirations. ## The solution @@ -78,6 +78,7 @@ Most importantly - it's easy to use. So check out the recipes below and give it 1. [**How to do perfom an action when a file is selected (opened)**](#) 1. [**How to filter the list of visible files/directories**](#) 1. [**How to write your own file tree plugin**](#) +1. [**How to restore the state of the file tree from local storage**](#) --- @@ -122,6 +123,10 @@ type FileTreeConfig = { * The root node data */ root?: Omit, "type">; + /** + * Restore the tree from a snapshot + */ + restoreFromSnapshot?: FileTreeSnapshot; }; ``` @@ -167,6 +172,15 @@ interface UseVirtualizeResult { * `true` if the viewport is currently scrolling */ isScrolling: boolean; + /** + * Scroll to the viewport a given position + * @param scrollTop - The new scroll position + * @param config - Configuration options + */ + scrollTo( + scrollTop: number, + config: Pick + ): void; /** * Scroll to a given node by its ID * @param nodeId - The node ID to scroll to @@ -551,6 +565,23 @@ A hook for subscribing to changes to the value of an observable. --- +### useFileTreeSnapshot() + +Take a snapshot of the expanded and buried directories of a file tree. +This snapshot can be used to restore the expanded/collapsed state of the +file tree when you initially load it. + +#### Arguments + +| Name | Type | Required? | Description | +| -------- | ---------------------------------------------------- | --------- | ---------------------------------------------- | +| fileTree | `FileTree` | Yes | A file tree | +| callback | `(state: FileTreeSnapshot) => Promise \| void` | Yes | A callback that handles the file tree snapshot | + +## [**⇗ Back to top**](#exploration) + +--- + ## Low-level API ### FileTree() @@ -599,22 +630,22 @@ A class for creating a directory node. #### Arguments -| Name | Type | Required? | Description | -| -------- | ----------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| parent | `Dir` | Yes | The parent node | -| data | [`FileTreeData`](#filetreedatameta) | Yes | The node data | -| expanded | `boolean` | No | Whether the node is expanded or not, defaults to `false`. This is an optimistic property, so when it is `true` its descendants may not be fully loaded yet. To get the more accurate representation, use `FileTree#isExpanded`. | +| Name | Type | Required? | Description | +| -------- | ------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| parent | `Dir` | Yes | The parent node | +| data | [`FileTreeData`](#filetreedata) | Yes | The node data | +| expanded | `boolean` | No | Whether the node is expanded or not, defaults to `false`. This is an optimistic property, so when it is `true` its descendants may not be fully loaded yet. To get the more accurate representation, use `FileTree#isExpanded`. | #### Properties -| Name | Type | Description | -| -------- | ----------------------------------------- | ------------------------------------ | -| parentId | `number` | The ID of the parent node. | -| parent | `Dir` | The parent node. | -| basename | `string` | The basename of the directory. | -| path | `string` | The full path of the directory. | -| expanded | `boolean` | `true` if the directory is expanded. | -| data | [`FileTreeData`](#filetreedatameta) | The node data | +| Name | Type | Description | +| -------- | ------------------------------------- | ------------------------------------ | +| parentId | `number` | The ID of the parent node. | +| parent | `Dir` | The parent node. | +| basename | `string` | The basename of the directory. | +| path | `string` | The full path of the directory. | +| expanded | `boolean` | `true` if the directory is expanded. | +| data | [`FileTreeData`](#filetreedata) | The node data | #### Methods diff --git a/package.json b/package.json index 3a42b68..39505bd 100755 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "@essentials/request-timeout": "^1.3.0", "@react-hook/hotkey": "^3.1.0", "clsx": "^1.1.1", - "is-relative": "^1.0.0", "trie-memoize": "^1.2.0", "use-subscription": "^1.6.0", "use-sync-external-store": "^1.0.0" @@ -118,7 +117,7 @@ }, "jest": { "collectCoverageFrom": [ - "**/src/**/*.{ts,tsx}" + "src/**/*.{ts,tsx}" ], "globals": { "__DEV__": true diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3432251..d460911 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,6 @@ specifiers: eslint: ^7.32.0 eslint-config-lunde: latest husky: latest - is-relative: ^1.0.0 jest: latest lint-staged: latest lundle: latest @@ -44,7 +43,6 @@ dependencies: '@essentials/request-timeout': 1.3.0 '@react-hook/hotkey': 3.1.0_react@18.0.0 clsx: 1.1.1 - is-relative: 1.0.0 trie-memoize: 1.2.0 use-subscription: 1.6.0_react@18.0.0 use-sync-external-store: 1.0.0_react@18.0.0 @@ -57,9 +55,9 @@ devDependencies: '@swc-node/core': 1.8.2 '@swc-node/jest': 1.4.3 '@testing-library/jest-dom': 5.16.4 - '@testing-library/react': 13.0.1_react-dom@18.0.0+react@18.0.0 + '@testing-library/react': 13.1.1_react-dom@18.0.0+react@18.0.0 '@testing-library/react-hooks': 8.0.0_cd4527864787a867d693ee49f6050614 - '@testing-library/user-event': 14.1.0 + '@testing-library/user-event': 14.1.1 '@types/is-relative': 1.0.0 '@types/jest': 27.4.1 '@types/react': 18.0.0 @@ -71,7 +69,7 @@ devDependencies: eslint-config-lunde: 0.7.1_a1aa4a46c63093a0ce0f642d68ebaa46 husky: 7.0.4 jest: 27.5.1 - lint-staged: 12.3.7 + lint-staged: 12.3.8 lundle: 0.4.13 prettier: 2.6.2 react: 18.0.0 @@ -2459,8 +2457,8 @@ packages: react-test-renderer: 18.0.0_react@18.0.0 dev: true - /@testing-library/react/13.0.1_react-dom@18.0.0+react@18.0.0: - resolution: {integrity: sha512-zeHx3PohYYp+4bTJwrixQY8zSBZjWUGwYc7OhD1EpWTHS92RleApLoP72NdwaWxOrM1P1Uezt3XvGf6t2XSWPQ==} + /@testing-library/react/13.1.1_react-dom@18.0.0+react@18.0.0: + resolution: {integrity: sha512-8mirlAa0OKaUvnqnZF6MdAh2tReYA2KtWVw1PKvaF5EcCZqgK5pl8iF+3uW90JdG5Ua2c2c2E2wtLdaug3dsVg==} engines: {node: '>=12'} peerDependencies: react: ^18.0.0 @@ -2473,8 +2471,8 @@ packages: react-dom: 18.0.0_react@18.0.0 dev: true - /@testing-library/user-event/14.1.0: - resolution: {integrity: sha512-+CGfMXlVM+OwREHDEsfTGsXIMI+rjr3a7YBUSutq7soELht+8kQrM5k46xa/WLfHdtX/wqsDIleL6bi4i+xz0w==} + /@testing-library/user-event/14.1.1: + resolution: {integrity: sha512-XrjH/iEUqNl9lF2HX9YhPNV7Amntkcnpw0Bo1KkRzowNDcgSN9i0nm4Q8Oi5wupgdfPaJNMAWa61A+voD6Kmwg==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@testing-library/dom': '>=7.21.4' @@ -5203,13 +5201,6 @@ packages: has-tostringtag: 1.0.0 dev: true - /is-relative/1.0.0: - resolution: {integrity: sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==} - engines: {node: '>=0.10.0'} - dependencies: - is-unc-path: 1.0.0 - dev: false - /is-shared-array-buffer/1.0.2: resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==} dependencies: @@ -5246,13 +5237,6 @@ packages: resolution: {integrity: sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=} dev: true - /is-unc-path/1.0.0: - resolution: {integrity: sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==} - engines: {node: '>=0.10.0'} - dependencies: - unc-path-regex: 0.1.2 - dev: false - /is-utf8/0.2.1: resolution: {integrity: sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=} dev: true @@ -5969,8 +5953,8 @@ packages: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} dev: true - /lint-staged/12.3.7: - resolution: {integrity: sha512-/S4D726e2GIsDVWIk1XGvheCaDm1SJRQp8efamZFWJxQMVEbOwSysp7xb49Oo73KYCdy97mIWinhlxcoNqIfIQ==} + /lint-staged/12.3.8: + resolution: {integrity: sha512-0+UpNaqIwKRSGAFOCcpuYNIv/j5QGVC+xUVvmSdxHO+IfIGoHbFLo3XcPmV/LLnsVj5EAncNHVtlITSoY5qWGQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true dependencies: @@ -7702,11 +7686,6 @@ packages: which-boxed-primitive: 1.0.2 dev: true - /unc-path-regex/0.1.2: - resolution: {integrity: sha1-5z3T17DXxe2G+6xrCufYxqadUPo=} - engines: {node: '>=0.10.0'} - dev: false - /unicode-canonical-property-names-ecmascript/2.0.0: resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} engines: {node: '>=4'} diff --git a/src/file-tree.test.ts b/src/file-tree.test.ts index cfff815..d1e1fe5 100644 --- a/src/file-tree.test.ts +++ b/src/file-tree.test.ts @@ -356,8 +356,7 @@ describe("createFileTree()", () => { insert( createDir({ name: "/.github/issue-templates", - }), - 1 + }) ); }); expect(node.nodes.length).toBe(mockFs["/.github"].length + 2); @@ -397,8 +396,7 @@ describe("createFileTree()", () => { insert( createFile({ name: "/.github/issue-templates", - }), - 1 + }) ); }); @@ -585,6 +583,27 @@ describe("createFileTree()", () => { tree.dispose(); expect(nodesById[tree.root.id]).toBe(undefined); }); + + it("should restore from a snapshot", async () => { + const tree = createFileTree(getNodesFromMockFs, { + restoreFromSnapshot: { + expandedPaths: ["/.github"], + buriedPaths: ["/.husky/hooks"], + version: 1, + }, + }); + await waitForTree(tree); + const dir = tree.getById(tree.visibleNodes[0]) as Dir; + expect(dir.expanded).toBe(true); + expect(dir.nodes).not.toBeUndefined(); + + const husky = tree.getById(tree.root.nodes[1]) as Dir; + await tree.expand(husky); + + const huskyHooks = tree.getById(husky.nodes[0]) as Dir; + expect(huskyHooks.expanded).toBe(true); + expect(huskyHooks.nodes).not.toBeUndefined(); + }); }); describe("file tree actions", () => { @@ -680,7 +699,7 @@ describe("isFile()", () => { function waitForTree(tree: Tree) { // @ts-expect-error: private access - return Promise.all(tree.pendingLoadChildrenRequests.values()); + return Promise.all(tree.loadingBranches.values()); } function getNodesFromMockFs(parent: any, { createFile, createDir }: any) { diff --git a/src/file-tree.ts b/src/file-tree.ts index 58efc78..59a4b32 100644 --- a/src/file-tree.ts +++ b/src/file-tree.ts @@ -4,6 +4,7 @@ import { Leaf } from "./tree/leaf"; import { nodesById } from "./tree/nodes-by-id"; import type { GetNodes as GetNodesBase } from "./tree/tree"; import { Tree } from "./tree/tree"; +import type { FileTreeSnapshot } from "./types"; /** * Create a file tree that can be used with the React API. @@ -17,9 +18,9 @@ export function createFileTree( getNodes: GetNodes, config: FileTreeConfig = {} ) { - const { comparator = defaultComparator, root } = config; + const { comparator = defaultComparator, root, restoreFromSnapshot } = config; - return new FileTree({ + const tree = new FileTree({ async getNodes(parent) { const factory: FileTreeFactory = { createFile(data) { @@ -27,7 +28,25 @@ export function createFileTree( }, createDir(data, expanded?: boolean) { - return new Dir(parent, data, expanded); + const path = + parent.id === -1 + ? data.name + : pathFx.join( + // @ts-expect-error: branch type but is dir + parent.path, + pathFx.basename(data.name) + ); + + return new Dir( + parent, + data, + expanded ?? + !!( + restoreFromSnapshot && + (restoreFromSnapshot.expandedPaths.includes(path) || + restoreFromSnapshot.buriedPaths.includes(path)) + ) + ); }, }; @@ -36,6 +55,8 @@ export function createFileTree( comparator, root: new Dir(null, root ? { ...root } : { name: "/" }), }); + + return tree; } export class FileTree extends Tree> { @@ -45,6 +66,8 @@ export class FileTree extends Tree> { declare root: Dir; protected declare treeNodeMap: Map | Dir>; declare nodesById: FileTreeNode[]; + protected declare loadingBranches: Map, Promise>; + /** * Get a node by its ID. * @@ -232,9 +255,9 @@ export class File extends Leaf> { /** * The full path of the file */ - get path() { + get path(): string { if (this.parentId > -1) { - return pathFx.join(this.parent!.data.name, this.basename); + return pathFx.join(this.parent!.path, this.basename); } return this.data.name; @@ -261,9 +284,9 @@ export class Dir extends Branch> { /** * The full path of the directory */ - get path() { + get path(): string { if (this.parentId > -1) { - return pathFx.join(this.parent!.data.name, this.basename); + return pathFx.join(this.parent!.path, this.basename); } return this.data.name; @@ -346,4 +369,8 @@ export type FileTreeConfig = { * The root node data */ root?: Omit, "type">; + /** + * Restore the tree from a snapshot + */ + restoreFromSnapshot?: FileTreeSnapshot; }; diff --git a/src/index.ts b/src/index.ts index 8e5a9ac..31c50d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ export { ObservableMap, ObservableSet } from "./observable-data"; export * as pathFx from "./path-fx"; export { observable } from "./tree/observable"; export type { Observable } from "./tree/observable"; +export type { FileTreeSnapshot, WindowRef } from "./types"; export { useDnd } from "./use-dnd"; export type { DndEvent, DndProps, UseDndPlugin, UseDndConfig } from "./use-dnd"; export { useFilter } from "./use-filter"; @@ -20,6 +21,7 @@ export { useHotkeys } from "./use-hotkeys"; export { useNodePlugins } from "./use-node-plugins"; export type { NodePlugin } from "./use-node-plugins"; export { useObservable } from "./use-observable"; +export { useFileTreeSnapshot } from "./use-file-tree-snapshot"; export { useTraits } from "./use-traits"; export type { TraitsProps, UseTraitsPlugin } from "./use-traits"; export { useRovingFocus } from "./use-roving-focus"; diff --git a/src/path-fx.test.ts b/src/path-fx.test.ts index 566b772..4994ce8 100644 --- a/src/path-fx.test.ts +++ b/src/path-fx.test.ts @@ -125,3 +125,12 @@ describe("normalize()", () => { expect(pathFx.normalize("/foo/bar/baz/qux/../../../")).toBe("/foo"); }); }); + +describe("isRelative()", () => { + it("should return true if the path is relative", () => { + expect(pathFx.isRelative("foo/bar")).toBe(true); + expect(pathFx.isRelative("foo")).toBe(true); + expect(pathFx.isRelative("/foo")).toBe(false); + expect(pathFx.isRelative("/foo/bar")).toBe(false); + }); +}); diff --git a/src/path-fx.ts b/src/path-fx.ts index e4818bf..f021f5f 100644 --- a/src/path-fx.ts +++ b/src/path-fx.ts @@ -1,11 +1,16 @@ -import isRelative from "is-relative"; - const ANY_LEADING_SEP_RE = /^[\\/]+/; const ANY_TRAILING_SEP_RE = /[\\/]+$/; const UNIX_JOIN_CHARACTER = "/"; const UNIX_SEP_NEGATE_RE = /[^/]+/g; -export { default as isRelative } from "is-relative"; +/** + * Returns `true` if the path is relative. + * + * @param path - The path to check + */ +export function isRelative(path: string): boolean { + return path.charAt(0) !== "/"; +} /** * Join all arguments together and normalize the resulting path. diff --git a/src/test/utils.ts b/src/test/utils.ts index b44da7c..1fb4946 100644 --- a/src/test/utils.ts +++ b/src/test/utils.ts @@ -48,7 +48,7 @@ export const mockFs = { export function waitForTree(tree: FileTree) { // @ts-expect-error: private access - return Promise.all(tree.pendingLoadChildrenRequests.values()); + return Promise.all(tree.loadingBranches.values()); } export function getNodesFromMockFs( diff --git a/src/tree/tree.ts b/src/tree/tree.ts index 81941ec..f6c458c 100644 --- a/src/tree/tree.ts +++ b/src/tree/tree.ts @@ -6,10 +6,7 @@ import { nodesById } from "./nodes-by-id"; import { observable } from "./observable"; export class Tree { - private pendingLoadChildrenRequests = new Map< - Branch, - Promise - >(); + protected loadingBranches = new Map, Promise>(); private getNodes: GetNodes; comparator?: (a: Node, b: Node) => number; flatView = observable(0); @@ -256,7 +253,7 @@ export class Tree { * @param branch - The branch to load nodes for */ async loadNodes(branch: Branch): Promise { - const promise = this.pendingLoadChildrenRequests.get(branch); + const promise = this.loadingBranches.get(branch); if (!promise) { const promise = (async (): Promise => { @@ -272,8 +269,8 @@ export class Tree { } })(); - promise.finally(() => this.pendingLoadChildrenRequests.delete(branch)); - this.pendingLoadChildrenRequests.set(branch, promise); + promise.finally(() => this.loadingBranches.delete(branch)); + this.loadingBranches.set(branch, promise); return promise; } diff --git a/src/types.ts b/src/types.ts index 51278ee..2acfda1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,3 +3,19 @@ export type WindowRef = | React.MutableRefObject | HTMLElement | null; + +export type FileTreeSnapshot = { + /** + * The expanded paths of the file tree. + */ + expandedPaths: string[]; + /** + * The buried paths of the file tree. That is, directories that are expanded + * but not visible. + */ + buriedPaths: string[]; + /** + * The version of the snapshot schema. + */ + version: 1; +}; diff --git a/src/use-file-tree-snapshot.test.ts b/src/use-file-tree-snapshot.test.ts new file mode 100644 index 0000000..87cc872 --- /dev/null +++ b/src/use-file-tree-snapshot.test.ts @@ -0,0 +1,52 @@ +import { renderHook } from "@testing-library/react-hooks"; +import { useFileTreeSnapshot } from "."; +import type { Dir } from "./file-tree"; +import { createFileTree } from "./file-tree"; +import { getNodesFromMockFs, waitForTree } from "./test/utils"; + +describe("useFileTreeSnapshot()", () => { + let fileTree = createFileTree(getNodesFromMockFs); + + afterEach(() => { + fileTree = createFileTree(getNodesFromMockFs); + }); + + it("should take snapshot of expanded directories", async () => { + await waitForTree(fileTree); + const handleSnapshot = jest.fn(); + renderHook(() => useFileTreeSnapshot(fileTree, handleSnapshot)); + + expect(handleSnapshot).not.toHaveBeenCalled(); + const dir = fileTree.getById(fileTree.visibleNodes[0]) as Dir; + await fileTree.expand(dir); + expect(handleSnapshot).lastCalledWith({ + expandedPaths: [dir.path], + buriedPaths: [], + version: 1, + }); + }); + + it("should take snapshot of buried directories", async () => { + await waitForTree(fileTree); + const handleSnapshot = jest.fn(); + renderHook(() => useFileTreeSnapshot(fileTree, handleSnapshot)); + + expect(handleSnapshot).not.toHaveBeenCalled(); + + const dir = fileTree.getById(fileTree.visibleNodes[4]) as Dir; + await fileTree.expand(dir, { recursive: true }); + expect(handleSnapshot).lastCalledWith({ + expandedPaths: ["/types/tree", "/types"], + buriedPaths: [], + version: 1, + }); + + fileTree.collapse(fileTree.getById(fileTree.visibleNodes[4]) as Dir); + + expect(handleSnapshot).lastCalledWith({ + expandedPaths: [], + buriedPaths: ["/types/tree"], + version: 1, + }); + }); +}); diff --git a/src/use-file-tree-snapshot.ts b/src/use-file-tree-snapshot.ts new file mode 100644 index 0000000..ae4b4ac --- /dev/null +++ b/src/use-file-tree-snapshot.ts @@ -0,0 +1,58 @@ +import { useObservable } from "."; +import type { FileTree } from "./file-tree"; +import { isDir } from "./file-tree"; +import type { FileTreeSnapshot } from "./types"; + +/** + * Take a snapshot of the expanded and buried directories of a file tree. + * This snapshot can be used to restore the expanded/collapsed state of the + * file tree when you initially load it. + * + * @param fileTree - A file tree + * @param callback - A callback that handles the file tree snapshot + */ +export function useFileTreeSnapshot( + fileTree: FileTree, + callback: (state: FileTreeSnapshot) => Promise | void +) { + useObservable(fileTree.flatView, () => { + const expandedPaths: string[] = []; + const nodeIds = [...fileTree.visibleNodes]; + const buriedIds: number[] = []; + let nodeId: number | undefined; + + while ((nodeId = nodeIds.pop())) { + const node = fileTree.getById(nodeId); + + if (!node) continue; + if (isDir(node)) { + if (node.expanded) { + expandedPaths.push(node.path); + } else if (node.nodes) { + buriedIds.push(...node.nodes); + } + } + } + + const buriedPaths: string[] = []; + + while ((nodeId = buriedIds.pop())) { + const node = fileTree.getById(nodeId); + + if (!node) continue; + if (isDir(node)) { + if (node.expanded) { + buriedPaths.push(node.path); + } + + if (node.nodes) { + buriedIds.push(...node.nodes); + } + } + } + + callback({ expandedPaths, buriedPaths, version: SNAPSHOT_VERSION }); + }); +} + +const SNAPSHOT_VERSION = 1; diff --git a/src/use-virtualize.test.ts b/src/use-virtualize.test.ts index 9e6b43d..206b906 100644 --- a/src/use-virtualize.test.ts +++ b/src/use-virtualize.test.ts @@ -94,6 +94,20 @@ describe("useVirtualize()", () => { expect(result.current.scrollTop).toBe(24 * 5); }); + it("should scroll to a given position", () => { + window.innerHeight = 48; + + const { result } = renderHook(() => + useVirtualize(fileTree, { windowRef: window, nodeHeight: 24 }) + ); + + act(() => { + result.current.scrollTo(48); + }); + + expect(result.current.scrollTop).toBe(48); + }); + it("should scroll to a node and center it", () => { window.innerHeight = 24 * 3; diff --git a/src/use-virtualize.ts b/src/use-virtualize.ts index 3cec600..2df98ae 100644 --- a/src/use-virtualize.ts +++ b/src/use-virtualize.ts @@ -42,6 +42,15 @@ export function useVirtualize( scrollTop: scrollPosition.scrollTop, isScrolling: scrollPosition.isScrolling, + scrollTo(scrollTop, config = {}) { + const windowEl = + windowRef && "current" in windowRef ? windowRef.current : windowRef; + + if (windowEl) { + windowEl.scrollTo({ top: scrollTop, behavior: config.behavior }); + } + }, + scrollToNode(nodeId, config = {}) { const index = visibleNodes.indexOf(nodeId) ?? -1; @@ -374,6 +383,16 @@ export interface UseVirtualizeResult { * `true` if the viewport is currently scrolling */ isScrolling: boolean; + /** + * Scroll to the viewport a given position + * + * @param scrollTop - The new scroll position + * @param config - Configuration options + */ + scrollTo( + scrollTop: number, + config?: Pick + ): void; /** * Scroll to a given node by its ID *