mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
web: use virtualized tree for rendering toc
This commit is contained in:
@@ -36,17 +36,22 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Cross } from "../icons";
|
||||
import React, { useEffect, useLayoutEffect, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Circle, Cross } from "../icons";
|
||||
import { useEditorStore } from "../../stores/editor-store";
|
||||
import ScrollContainer from "../scroll-container";
|
||||
import { ScopedThemeProvider } from "../theme-provider";
|
||||
import { Section } from "../properties";
|
||||
import { scrollIntoViewById } from "@notesnook/editor";
|
||||
import { scrollIntoViewById, TOCItem } from "@notesnook/editor";
|
||||
import { Button, Flex, Text } from "@theme-ui/components";
|
||||
import { useEditorManager } from "./manager";
|
||||
import { TITLE_BAR_HEIGHT } from "../title-bar";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import {
|
||||
TreeNode,
|
||||
VirtualizedTree,
|
||||
VirtualizedTreeHandle
|
||||
} from "../virtualized-tree";
|
||||
|
||||
type TableOfContentsProps = {
|
||||
sessionId: string;
|
||||
@@ -54,34 +59,35 @@ type TableOfContentsProps = {
|
||||
function TableOfContents(props: TableOfContentsProps) {
|
||||
const { sessionId } = props;
|
||||
|
||||
const [active, setActive] = useState<string[]>([]);
|
||||
const toggleTableOfContents = useEditorStore(
|
||||
(store) => store.toggleTableOfContents
|
||||
);
|
||||
const treeRef = React.useRef<VirtualizedTreeHandle<TOCItem>>(null);
|
||||
const tableOfContents = useEditorManager(
|
||||
(store) => store.editors[sessionId]?.tableOfContents || []
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
treeRef.current?.refresh();
|
||||
const editorScroll = document.getElementById(`editorScroll_${sessionId}`);
|
||||
if (!editorScroll) return;
|
||||
function onScroll() {
|
||||
const scrollTop = editorScroll?.scrollTop || 0;
|
||||
const height = editorScroll?.clientHeight || 0;
|
||||
const active = tableOfContents.filter((t, i, array) => {
|
||||
const next = array.at(i + 1);
|
||||
for (let i = 0; i < tableOfContents.length; i++) {
|
||||
const toc = tableOfContents[i];
|
||||
const element = document.getElementById(`toc-${toc.id}`);
|
||||
if (!element) continue;
|
||||
|
||||
const next = tableOfContents.at(i + 1);
|
||||
const viewport = scrollTop + height - 160;
|
||||
const lessThanNext = next ? scrollTop <= next.top : true;
|
||||
const isInViewport = t.top <= viewport && t.top >= scrollTop;
|
||||
const isActive = scrollTop >= t.top && lessThanNext;
|
||||
return isInViewport || isActive;
|
||||
});
|
||||
setActive(active.map((a) => a.id));
|
||||
const isInViewport = toc.top <= viewport && toc.top >= scrollTop;
|
||||
const isActive = scrollTop >= toc.top && lessThanNext;
|
||||
|
||||
element.classList.toggle("active", isActive || isInViewport);
|
||||
}
|
||||
}
|
||||
editorScroll.addEventListener("scroll", onScroll);
|
||||
editorScroll?.addEventListener("scroll", onScroll);
|
||||
onScroll();
|
||||
return () => {
|
||||
editorScroll.removeEventListener("scroll", onScroll);
|
||||
editorScroll?.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, [sessionId, tableOfContents]);
|
||||
|
||||
@@ -107,58 +113,100 @@ function TableOfContents(props: TableOfContentsProps) {
|
||||
flexDirection: "column"
|
||||
}}
|
||||
>
|
||||
<ScrollContainer>
|
||||
<Section
|
||||
title={strings.toc()}
|
||||
buttonPosition="right"
|
||||
button={
|
||||
<Cross
|
||||
data-test-id="toc-close"
|
||||
onClick={() => toggleTableOfContents(false)}
|
||||
size={18}
|
||||
sx={{ mr: 1, cursor: "pointer" }}
|
||||
/>
|
||||
}
|
||||
<Section title={strings.toc()} sx={{ flex: 1 }}>
|
||||
<Flex
|
||||
sx={{
|
||||
mt: 1,
|
||||
flexDirection: "column",
|
||||
mx: 1,
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
<Flex sx={{ mt: 2, flexDirection: "column" }}>
|
||||
{tableOfContents.length <= 0 ? (
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
pl: 1
|
||||
}}
|
||||
>
|
||||
{strings.noHeadingsFound()}.
|
||||
</Text>
|
||||
) : (
|
||||
tableOfContents.map((t) => (
|
||||
{tableOfContents.length <= 0 ? (
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
pl: 1
|
||||
}}
|
||||
>
|
||||
{strings.noHeadingsFound()}.
|
||||
</Text>
|
||||
) : (
|
||||
<VirtualizedTree
|
||||
treeRef={treeRef}
|
||||
rootId="root"
|
||||
itemHeight={27}
|
||||
getChildNodes={async (id, depth) => {
|
||||
const nodes: TreeNode<TOCItem>[] = [];
|
||||
for (let i = 0; i < tableOfContents.length; i++) {
|
||||
const item = tableOfContents[i];
|
||||
if (item.level !== depth + 1) continue;
|
||||
nodes.push({
|
||||
id: item.id,
|
||||
data: item,
|
||||
depth: depth + 1,
|
||||
parentId: id,
|
||||
hasChildren:
|
||||
i + 1 < tableOfContents.length &&
|
||||
tableOfContents[i + 1].level > item.level,
|
||||
expanded: true
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
}}
|
||||
renderItem={({ item, collapse, expand, expanded }) => (
|
||||
<Button
|
||||
variant="menuitem"
|
||||
key={t.id}
|
||||
key={item.id}
|
||||
id={`toc-${item.id}`}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
paddingLeft: `${t.level * 10 + (t.level - 1) * 10}px`,
|
||||
borderRadius: "default",
|
||||
marginLeft: `${
|
||||
item.depth === 0 ? 0 : 5 + 10 * item.depth
|
||||
}px`,
|
||||
px: 1,
|
||||
py: 1,
|
||||
pr: 1,
|
||||
borderLeft: "5px solid transparent",
|
||||
borderColor: active.includes(t.id)
|
||||
? "accent-selected"
|
||||
: "transparent",
|
||||
color: active.includes(t.id)
|
||||
? "accent-selected"
|
||||
: "paragraph"
|
||||
"&.active": {
|
||||
color: "accent-selected"
|
||||
},
|
||||
"&.active path": {
|
||||
fill: "var(--accent-selected) !important"
|
||||
}
|
||||
}}
|
||||
onClick={() =>
|
||||
scrollIntoViewById(t.id, "scroll-margin-top: 35px;")
|
||||
scrollIntoViewById(item.id, "scroll-margin-top: 35px;")
|
||||
}
|
||||
>
|
||||
{t.title}
|
||||
{item.hasChildren ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
sx={{ bg: "transparent", p: 0, borderRadius: 100 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
expanded ? collapse() : expand();
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown size={14} color="icon-secondary" />
|
||||
) : (
|
||||
<ChevronRight size={14} color="icon-secondary" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Circle size={5} color="icon-secondary" />
|
||||
)}
|
||||
{item.data.title}
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</Flex>
|
||||
</Section>
|
||||
</ScrollContainer>
|
||||
)}
|
||||
></VirtualizedTree>
|
||||
)}
|
||||
</Flex>
|
||||
</Section>
|
||||
</ScopedThemeProvider>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user