From 2e8173f6ca86c0e58abcdde1e85f393cc0ddc45a Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Wed, 21 May 2025 11:00:58 +0500 Subject: [PATCH] web: add support for highlighting in search results --- .../components/editor/table-of-contents.tsx | 15 +- .../src/components/search-result/index.tsx | 137 ++++++++++++++++++ .../src/components/virtualized-tree/index.tsx | 48 +++--- apps/web/src/dialogs/move-note-dialog.tsx | 2 +- apps/web/src/views/all-notes.tsx | 113 ++++++++++++++- apps/web/src/views/notebooks.tsx | 24 +-- 6 files changed, 286 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/components/search-result/index.tsx diff --git a/apps/web/src/components/editor/table-of-contents.tsx b/apps/web/src/components/editor/table-of-contents.tsx index 349c33c35..6f75bebb9 100644 --- a/apps/web/src/components/editor/table-of-contents.tsx +++ b/apps/web/src/components/editor/table-of-contents.tsx @@ -134,18 +134,19 @@ function TableOfContents(props: TableOfContentsProps) { treeRef={treeRef} rootId="root" itemHeight={27} - getChildNodes={async (id, depth) => { + getChildNodes={async (parent) => { const remainingToc = - id === "root" + parent.id === "root" ? tableOfContents : tableOfContents.slice( tableOfContents.findIndex((item) => item.id === id) ); - let items: typeof remainingToc = []; + const items: typeof remainingToc = []; let added = false; for (let i = 0; i < remainingToc.length; i++) { - if (added && depth + 1 > remainingToc[i].level) break; + if (added && parent.depth + 1 > remainingToc[i].level) + break; items.push(remainingToc[i]); added = true; @@ -154,12 +155,12 @@ function TableOfContents(props: TableOfContentsProps) { const nodes: TreeNode[] = []; for (let i = 0; i < items.length; i++) { const item = items[i]; - if (item.level !== depth + 1) continue; + if (item.level !== parent.depth + 1) continue; nodes.push({ id: item.id, data: item, - depth: depth + 1, - parentId: id, + depth: parent.depth + 1, + parentId: parent.id, hasChildren: i + 1 < items.length && items[i + 1].level > item.level, expanded: true diff --git a/apps/web/src/components/search-result/index.tsx b/apps/web/src/components/search-result/index.tsx new file mode 100644 index 000000000..3e4a8a0b2 --- /dev/null +++ b/apps/web/src/components/search-result/index.tsx @@ -0,0 +1,137 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { HighlightedResult, Match } from "@notesnook/core"; +import { Button, Flex, Text } from "@theme-ui/components"; +import React from "react"; +import { useEditorStore } from "../../stores/editor-store"; + +import ListItem from "../list-item"; +import { ChevronDown, ChevronRight } from "../icons"; + +type SearchResultProps = { + item: HighlightedResult; + match?: Match[]; + depth: number; + isExpandable: boolean; + isExpanded: boolean; + collapse: () => void; + expand: () => void; +}; + +function SearchResult(props: SearchResultProps) { + const { item, match, collapse, depth, expand, isExpandable, isExpanded } = + props; + + const isOpened = useEditorStore((store) => store.isNoteOpen(item.id)); + + return ( + + useEditorStore + .getState() + .openSession(item.id, { considerPinnedTab: true }) + } + onMiddleClick={() => + useEditorStore.getState().openSession(item.id, { openInNewTab: true }) + } + title={ + + {isExpandable ? ( + + ) : null} + + {match + ? match.map((match) => ( + <> + {match.prefix} + {match.match} + {match.suffix ? {match.suffix} : null} + + )) + : item.title.map((match) => ( + <> + {match.prefix} + {match.match} + {match.suffix ? {match.suffix} : null} + + ))} + + + } + footer={ + match ? undefined : ( + + {item.content?.reduce((count, next) => next.length + count, 0) || 0} + + ) + } + sx={{ + mb: "small", + borderRadius: "default", + paddingLeft: `${5 + (depth === 0 ? 0 : 15 * depth)}px` + }} + /> + ); +} + +export default React.memo(SearchResult); diff --git a/apps/web/src/components/virtualized-tree/index.tsx b/apps/web/src/components/virtualized-tree/index.tsx index fccdb8edd..d56fed135 100644 --- a/apps/web/src/components/virtualized-tree/index.tsx +++ b/apps/web/src/components/virtualized-tree/index.tsx @@ -39,7 +39,7 @@ export type VirtualizedTreeHandle = { }; export type TreeNode = { id: string; - parentId: string; + parentId?: string; depth: number; hasChildren: boolean; data: T; @@ -49,8 +49,8 @@ type ExpandedIds = Record; type TreeViewProps = { treeRef?: React.Ref> | null; rootId: string; - itemHeight: number; - getChildNodes: (id: string, depth: number) => Promise[]>; + itemHeight?: number; + getChildNodes: (parent: TreeNode) => Promise[]>; renderItem: (props: { item: TreeNode; index: number; @@ -101,8 +101,7 @@ export function VirtualizedTree(props: TreeViewProps) { () => ({ async refresh() { const { children } = await fetchChildren( - rootId, - -1, + { id: rootId, depth: -1, data: {} as T, hasChildren: true }, expandedIds, getChildNodes ); @@ -117,7 +116,7 @@ export function VirtualizedTree(props: TreeViewProps) { const node = nodes[index]; const removeIds: string[] = [node.id]; for (const treeNode of nodes) { - if (removeIds.includes(treeNode.parentId)) { + if (treeNode.parentId && removeIds.includes(treeNode.parentId)) { removeIds.push(treeNode.id); } } @@ -128,14 +127,13 @@ export function VirtualizedTree(props: TreeViewProps) { return; } - const childNodes = await getChildNodes(node.id, node.depth); + const childNodes = await getChildNodes(node); if (childNodes.length > 0 && itemOptions?.expand) { expandedIds[node.id] = true; } const { children } = await fetchChildren( - node.id, - node.depth, + node, expandedIds, getChildNodes ); @@ -202,12 +200,14 @@ export function VirtualizedTree(props: TreeViewProps) { }); useEffect(() => { - fetchChildren(rootId, -1, expandedIds, getChildNodes).then( - ({ children, expandedIds }) => { - setNodes(children); - setExpandedIds(expandedIds); - } - ); + fetchChildren( + { depth: -1, id: rootId, data: {} as T, hasChildren: true }, + expandedIds, + getChildNodes + ).then(({ children, expandedIds }) => { + setNodes(children); + setExpandedIds(expandedIds); + }); console.log("fetching"); }, [rootId]); @@ -242,8 +242,9 @@ export function VirtualizedTree(props: TreeViewProps) { const removeIds: string[] = []; for (const treeNode of tree) { if ( - treeNode.parentId === node.id || - removeIds.includes(treeNode.parentId) + treeNode.parentId && + (treeNode.parentId === node.id || + removeIds.includes(treeNode.parentId)) ) { removeIds.push(treeNode.id); } @@ -256,8 +257,7 @@ export function VirtualizedTree(props: TreeViewProps) { if (expandedIds[node.id]) return; const { children } = await fetchChildren( - node.id, - node.depth, + node, expandedIds, getChildNodes ); @@ -298,12 +298,11 @@ function VirtuosoItem({ } async function fetchChildren( - id: string, - depth: number, + node: TreeNode, expandedIds: ExpandedIds, - getChildNodes: (id: string, depth: number) => Promise[]> + getChildNodes: TreeViewProps["getChildNodes"] ) { - const children = await getChildNodes(id, depth); + const children = await getChildNodes(node); for (let i = 0; i < children.length; i++) { const childNode = children[i]; if ( @@ -314,8 +313,7 @@ async function fetchChildren( ) { expandedIds[childNode.id] = true; const { children: nodes } = await fetchChildren( - childNode.id, - childNode.depth, + childNode, expandedIds, getChildNodes ); diff --git a/apps/web/src/dialogs/move-note-dialog.tsx b/apps/web/src/dialogs/move-note-dialog.tsx index c7dbe12e0..ee393ec7b 100644 --- a/apps/web/src/dialogs/move-note-dialog.tsx +++ b/apps/web/src/dialogs/move-note-dialog.tsx @@ -219,7 +219,7 @@ export const MoveNoteDialog = DialogManager.register(function MoveNoteDialog({ rootId={"root"} itemHeight={30} treeRef={treeRef} - getChildNodes={async (id, depth) => { + getChildNodes={async ({ id, depth }) => { const nodes: TreeNode[] = []; if (id === "root") { for (const id of notebooks) { diff --git a/apps/web/src/views/all-notes.tsx b/apps/web/src/views/all-notes.tsx index e6344782e..021810672 100644 --- a/apps/web/src/views/all-notes.tsx +++ b/apps/web/src/views/all-notes.tsx @@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import React, { useEffect } from "react"; +import React, { useEffect, useRef } from "react"; import { useStore } from "../stores/note-store"; import ListContainer from "../components/list-container"; import useNavigate from "../hooks/use-navigate"; @@ -26,8 +26,24 @@ import { useSearch } from "../hooks/use-search"; import { db } from "../common/db"; import { useEditorStore } from "../stores/editor-store"; import { ListLoader } from "../components/loaders/list-loader"; +import { Box, Text } from "@theme-ui/components"; +import { strings } from "@notesnook/intl"; +import { + TreeNode, + VirtualizedTree, + VirtualizedTreeHandle +} from "../components/virtualized-tree"; +import SearchResult from "../components/search-result"; +import { HighlightedResult, Match } from "@notesnook/core"; +import GroupHeader from "../components/group-header"; function Home() { + const treeRef = useRef< + VirtualizedTreeHandle<{ + item: HighlightedResult; + match?: Match[]; + }> + >(null); const notes = useStore((store) => store.notes); const isCompact = useStore((store) => store.viewMode === "compact"); const refresh = useStore((store) => store.refresh); @@ -36,7 +52,7 @@ function Home() { "notes", async (query, sortOptions) => { if (useStore.getState().context) return; - return await db.lookup.notes(query).sorted(sortOptions); + return await db.lookup.notes(query, sortOptions); }, [notes] ); @@ -47,6 +63,10 @@ function Home() { useStore.getState().refresh(); }, []); + useEffect(() => { + treeRef.current?.resetAndRefresh(); + }, [filteredItems]); + // useEffect(() => { // (async function () { @@ -64,6 +84,94 @@ function Home() { // })(); // }, []); + if (filteredItems) { + return ( + + {filteredItems.length === 0 ? ( + + {strings.noResultsFound()} + + ) : ( + <> + {}} + groups={async () => []} + onJump={() => {}} + /> + { + const nodes: TreeNode<{ + item: HighlightedResult; + match?: Match[]; + }>[] = []; + if (parent.id === "root") { + for (let i = 0; i < filteredItems.length; ++i) { + const result = await filteredItems.item(i); + if (!result.item) continue; + nodes.push({ + data: { item: result.item }, + depth: parent.depth + 1, + hasChildren: !!result.item.content?.length, + id: result.item.id, + parentId: parent.id, + expanded: true + }); + } + } else { + let i = 0; + for (const match of parent.data.item.content || []) { + nodes.push({ + data: { item: parent.data.item, match }, + depth: parent.depth + 1, + parentId: parent.id, + id: parent.id + i++, + hasChildren: false + }); + } + } + return nodes; + }} + renderItem={({ collapse, expand, expanded, item: node }) => ( + + )} + /> + + )} + + ); + } + if (!notes) return ; return ( } button={{ onClick: () => useEditorStore.getState().newSession() diff --git a/apps/web/src/views/notebooks.tsx b/apps/web/src/views/notebooks.tsx index 34c602d6a..88a03e570 100644 --- a/apps/web/src/views/notebooks.tsx +++ b/apps/web/src/views/notebooks.tsx @@ -18,18 +18,11 @@ along with this program. If not, see . */ import { Notebook as NotebookType, VirtualizedGrouping } from "@notesnook/core"; -import { Box, Flex, Input, Text } from "@theme-ui/components"; -import { - forwardRef, - useEffect, - useLayoutEffect, - useRef, - useState -} from "react"; +import { Box, Input, Text } from "@theme-ui/components"; +import { useEffect, useRef, useState } from "react"; import { db } from "../common/db"; import { store, useStore } from "../stores/notebook-store"; import { useStore as useSelectionStore } from "../stores/selection-store"; -import Placeholder from "../components/placeholders"; import { Notebook } from "../components/notebook"; import { TreeNode, @@ -39,9 +32,6 @@ import { import { ListLoader } from "../components/loaders/list-loader"; import { debounce } from "@notesnook/common"; import { strings } from "@notesnook/intl"; -import { CustomScrollbarsVirtualList } from "../components/list-container"; -import { ScrollerProps } from "react-virtuoso"; -import ScrollContainer from "../components/scroll-container"; import { SidebarScroller } from "../components/sidebar-scroller"; export function Notebooks() { @@ -107,16 +97,16 @@ export function Notebooks() { onDeselect={deselectItem} onSelect={selectItem} saveKey="notebook-tree" - getChildNodes={async (parentId, depth) => { + getChildNodes={async (parent) => { const nodes: TreeNode<{ notebook: NotebookType; totalNotes: number; }>[] = []; const grouping = - parentId === "root" + parent.id === "root" ? notebooks : await db.relations - .from({ type: "notebook", id: parentId }, "notebook") + .from({ type: "notebook", id: parent.id }, "notebook") .selector.sorted( db.settings.getGroupOptions("notebooks") ); @@ -125,10 +115,10 @@ export function Notebooks() { if (!notebook.item) continue; nodes.push({ data: { notebook: notebook.item, totalNotes: 0 }, - depth: depth + 1, + depth: parent.depth + 1, hasChildren: false, id: notebook.item.id, - parentId + parentId: parent.id }); } const allRelations = await db.relations