web: add support for highlighting in search results

This commit is contained in:
Abdullah Atta
2025-05-21 11:00:58 +05:00
committed by Abdullah Atta
parent 33b6e3a19d
commit 2e8173f6ca
6 changed files with 286 additions and 53 deletions

View File

@@ -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<TOCItem>[] = [];
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

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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 (
<ListItem
isFocused={isOpened}
isCompact={!match}
item={item}
onClick={() =>
useEditorStore
.getState()
.openSession(item.id, { considerPinnedTab: true })
}
onMiddleClick={() =>
useEditorStore.getState().openSession(item.id, { openInNewTab: true })
}
title={
<Flex sx={{ alignItems: "center", gap: "small" }}>
{isExpandable ? (
<Button
variant="secondary"
sx={{ bg: "transparent", p: 0, borderRadius: 100 }}
onClick={(e) => {
e.stopPropagation();
isExpanded ? collapse() : expand();
}}
>
{isExpanded ? (
<ChevronDown
size={14}
color={isOpened ? "icon-selected" : "icon"}
/>
) : (
<ChevronRight
size={14}
color={isOpened ? "icon-selected" : "icon"}
/>
)}
</Button>
) : null}
<Text
data-test-id={`title`}
variant={"body"}
color={isOpened ? "paragraph-selected" : "paragraph"}
sx={{
...(match
? {
whiteSpace: "pre-wrap"
}
: {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
}),
fontWeight: "body",
display: "block",
".match": {
bg: "accent-secondary",
color: "accentForeground-secondary"
}
}}
>
{match
? match.map((match) => (
<>
<span>{match.prefix}</span>
<span className="match">{match.match}</span>
{match.suffix ? <span>{match.suffix}</span> : null}
</>
))
: item.title.map((match) => (
<>
<span>{match.prefix}</span>
<span className="match">{match.match}</span>
{match.suffix ? <span>{match.suffix}</span> : null}
</>
))}
</Text>
</Flex>
}
footer={
match ? undefined : (
<Text variant="subBody">
{item.content?.reduce((count, next) => next.length + count, 0) || 0}
</Text>
)
}
sx={{
mb: "small",
borderRadius: "default",
paddingLeft: `${5 + (depth === 0 ? 0 : 15 * depth)}px`
}}
/>
);
}
export default React.memo(SearchResult);

View File

@@ -39,7 +39,7 @@ export type VirtualizedTreeHandle<T> = {
};
export type TreeNode<T = any> = {
id: string;
parentId: string;
parentId?: string;
depth: number;
hasChildren: boolean;
data: T;
@@ -49,8 +49,8 @@ type ExpandedIds = Record<string, boolean>;
type TreeViewProps<T> = {
treeRef?: React.Ref<VirtualizedTreeHandle<T>> | null;
rootId: string;
itemHeight: number;
getChildNodes: (id: string, depth: number) => Promise<TreeNode<T>[]>;
itemHeight?: number;
getChildNodes: (parent: TreeNode<T>) => Promise<TreeNode<T>[]>;
renderItem: (props: {
item: TreeNode<T>;
index: number;
@@ -101,8 +101,7 @@ export function VirtualizedTree<T>(props: TreeViewProps<T>) {
() => ({
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<T>(props: TreeViewProps<T>) {
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<T>(props: TreeViewProps<T>) {
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<T>(props: TreeViewProps<T>) {
});
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<T>(props: TreeViewProps<T>) {
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<T>(props: TreeViewProps<T>) {
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<T>(
id: string,
depth: number,
node: TreeNode<T>,
expandedIds: ExpandedIds,
getChildNodes: (id: string, depth: number) => Promise<TreeNode<T>[]>
getChildNodes: TreeViewProps<T>["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<T>(
) {
expandedIds[childNode.id] = true;
const { children: nodes } = await fetchChildren(
childNode.id,
childNode.depth,
childNode,
expandedIds,
getChildNodes
);

View File

@@ -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<Notebook>[] = [];
if (id === "root") {
for (const id of notebooks) {

View File

@@ -17,7 +17,7 @@ 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 } 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 (
<Box
id="search-results"
sx={{
flex: 1,
'[data-viewport-type="element"]': {
px: 1,
width: `calc(100% - ${2 * 6}px) !important`
}
}}
>
{filteredItems.length === 0 ? (
<Text
variant="body"
sx={{ color: "paragraph-secondary", mx: 1 }}
data-test-id="list-placeholder"
>
{strings.noResultsFound()}
</Text>
) : (
<>
<GroupHeader
groupingKey={"search"}
isSearching={true}
refresh={refresh}
title={`${filteredItems.length} results`}
isFocused={false}
index={0}
onSelectGroup={() => {}}
groups={async () => []}
onJump={() => {}}
/>
<VirtualizedTree
treeRef={treeRef}
testId="search-results-list"
rootId={"root"}
getChildNodes={async (parent) => {
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 }) => (
<SearchResult
key={node.id}
depth={node.depth}
isExpandable={node.hasChildren}
item={node.data.item}
match={node.data.match}
isExpanded={expanded}
collapse={collapse}
expand={expand}
/>
)}
/>
</>
)}
</Box>
);
}
if (!notes) return <ListLoader />;
return (
<ListContainer
@@ -72,7 +180,6 @@ function Home() {
compact={isCompact}
refresh={refresh}
items={filteredItems || notes}
isSearching={!!filteredItems}
placeholder={<Placeholder context={filteredItems ? "search" : "notes"} />}
button={{
onClick: () => useEditorStore.getState().newSession()

View File

@@ -18,18 +18,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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