web: fix scroll to search result (#8290)

* web: (wip) fix scroll to search result
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: fix scroll to search result
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
This commit is contained in:
01zulfi
2025-07-03 12:16:25 +05:00
committed by GitHub
parent 1cd21087c2
commit a349611a30
7 changed files with 71 additions and 50 deletions

View File

@@ -242,8 +242,7 @@ const MemoizedEditorView = React.memo(EditorView, (prev, next) => {
prev.session.type === next.session.type && prev.session.type === next.session.type &&
prev.session.needsHydration === next.session.needsHydration && prev.session.needsHydration === next.session.needsHydration &&
prev.session.activeBlockId === next.session.activeBlockId && prev.session.activeBlockId === next.session.activeBlockId &&
prev.session.activeSearchResultIndex === prev.session.activeSearchResultId === next.session.activeSearchResultId
next.session.activeSearchResultIndex
); );
}); });
function EditorView({ function EditorView({
@@ -855,28 +854,29 @@ function useScrollToBlock(session: EditorSession) {
} }
function useScrollToSearchResult(session: EditorSession) { function useScrollToSearchResult(session: EditorSession) {
const index = useEditorStore( const id = useEditorStore(
(store) => store.getSession(session.id)?.activeSearchResultIndex (store) => store.getSession(session.id)?.activeSearchResultId
); );
useEffect(() => { useEffect(() => {
if (index === undefined) return; if (id === undefined) return;
const scrollContainer = document.getElementById( const scrollContainer = document.getElementById(
`editorScroll_${session.id}` `editorScroll_${session.id}`
); );
scrollContainer?.closest(".active")?.classList.add("searching"); scrollContainer?.closest(".active")?.classList.add("searching");
const elements = scrollContainer?.getElementsByTagName("nn-search-result"); const element = scrollContainer?.querySelector(`nn-search-result#${id}`);
setTimeout( setTimeout(
() => () =>
elements element?.scrollIntoView({
?.item(index) block: "center",
?.scrollIntoView({ block: "center", behavior: "instant" }), behavior: "instant"
}),
100 100
); );
useEditorStore.getState().updateSession(session.id, [session.type], { useEditorStore.getState().updateSession(session.id, [session.type], {
activeSearchResultIndex: undefined activeSearchResultId: undefined
}); });
}, [session.id, session.type, index]); }, [session.id, session.type, id]);
} }
function isFile(e: DragEvent) { function isFile(e: DragEvent) {

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { HighlightedResult } from "@notesnook/core"; import { HighlightedResult } from "@notesnook/core";
import { Button, Flex, Text } from "@theme-ui/components"; import { Button, Flex, Text } from "@theme-ui/components";
import React, { useState } from "react"; import React, { Fragment, useState } from "react";
import { useEditorStore } from "../../stores/editor-store"; import { useEditorStore } from "../../stores/editor-store";
import { useStore as useNoteStore } from "../../stores/note-store"; import { useStore as useNoteStore } from "../../stores/note-store";
import ListItem from "../list-item"; import ListItem from "../list-item";
@@ -112,11 +112,11 @@ function SearchResult(props: SearchResultProps) {
}} }}
> >
{item.title.map((match) => ( {item.title.map((match) => (
<> <Fragment key={match.id}>
<span>{match.prefix}</span> <span>{match.prefix}</span>
<span className="match">{match.match}</span> <span className="match">{match.match}</span>
{match.suffix ? <span>{match.suffix}</span> : null} {match.suffix ? <span>{match.suffix}</span> : null}
</> </Fragment>
))} ))}
</Text> </Text>
@@ -146,14 +146,14 @@ function SearchResult(props: SearchResultProps) {
useEditorStore.getState().openSession(item.id, { useEditorStore.getState().openSession(item.id, {
rawContent: item.rawContent, rawContent: item.rawContent,
force: true, force: true,
activeSearchResultIndex: findSelectedMatchIndex(item, index) activeSearchResultId: match[0].id
}); });
}} }}
onMiddleClick={() => { onMiddleClick={() => {
useEditorStore.getState().openSession(item.id, { useEditorStore.getState().openSession(item.id, {
openInNewTab: true, openInNewTab: true,
rawContent: item.rawContent, rawContent: item.rawContent,
activeSearchResultIndex: findSelectedMatchIndex(item, index) activeSearchResultId: match[0].id
}); });
}} }}
title={ title={
@@ -171,11 +171,11 @@ function SearchResult(props: SearchResultProps) {
}} }}
> >
{match.map((match) => ( {match.map((match) => (
<> <Fragment key={match.id}>
<span>{match.prefix}</span> <span>{match.prefix}</span>
<span className="match">{match.match}</span> <span className="match">{match.match}</span>
{match.suffix ? <span>{match.suffix}</span> : null} {match.suffix ? <span>{match.suffix}</span> : null}
</> </Fragment>
))} ))}
</Text> </Text>
} }
@@ -215,11 +215,3 @@ async function menuItems(
color: colors[0] color: colors[0]
}); });
} }
function findSelectedMatchIndex(item: HighlightedResult, matchIndex: number) {
let activeIndex = 0;
for (let i = 0; i <= matchIndex - 1; ++i) {
activeIndex += item.content[i].length;
}
return activeIndex;
}

View File

@@ -85,9 +85,9 @@ export type BaseEditorSession = {
*/ */
activeBlockId?: string; activeBlockId?: string;
/** /**
* The index of search result to scroll to after opening the session successfully. * The id of search result to scroll to after opening the session successfully.
*/ */
activeSearchResultIndex?: number; activeSearchResultId?: string;
}; };
export type LockedEditorSession = BaseEditorSession & { export type LockedEditorSession = BaseEditorSession & {
@@ -666,7 +666,7 @@ class EditorStore extends BaseStore<EditorStore> {
silent?: boolean; silent?: boolean;
openInNewTab?: boolean; openInNewTab?: boolean;
rawContent?: string; rawContent?: string;
activeSearchResultIndex?: number; activeSearchResultId?: string;
} = {} } = {}
): Promise<void> => { ): Promise<void> => {
const { const {
@@ -790,7 +790,7 @@ class EditorStore extends BaseStore<EditorStore> {
id: sessionId, id: sessionId,
content, content,
activeBlockId: options.activeBlockId, activeBlockId: options.activeBlockId,
activeSearchResultIndex: options.activeSearchResultIndex, activeSearchResultId: options.activeSearchResultId,
tabId tabId
}, },
options.silent options.silent
@@ -814,7 +814,7 @@ class EditorStore extends BaseStore<EditorStore> {
color: colors[0]?.fromId, color: colors[0]?.fromId,
tags, tags,
activeBlockId: options.activeBlockId, activeBlockId: options.activeBlockId,
activeSearchResultIndex: options.activeSearchResultIndex, activeSearchResultId: options.activeSearchResultId,
tabId tabId
}, },
options.silent options.silent
@@ -835,7 +835,7 @@ class EditorStore extends BaseStore<EditorStore> {
? { ...content, data: options.rawContent } ? { ...content, data: options.rawContent }
: content, : content,
activeBlockId: options.activeBlockId, activeBlockId: options.activeBlockId,
activeSearchResultIndex: options.activeSearchResultIndex, activeSearchResultId: options.activeSearchResultId,
tabId tabId
}, },
options.silent options.silent

View File

@@ -62,10 +62,8 @@ type FuzzySearchField<T> = {
}; };
const MATCH_TAG_NAME = "nn-search-result"; const MATCH_TAG_NAME = "nn-search-result";
const MATCH_TAG_OPEN = `<${MATCH_TAG_NAME}>`;
const MATCH_TAG_CLOSE = `</${MATCH_TAG_NAME}>`;
const MATCH_TAG_REGEX = new RegExp( const MATCH_TAG_REGEX = new RegExp(
`<${MATCH_TAG_NAME}>(.*?)<\\/${MATCH_TAG_NAME}>`, `<${MATCH_TAG_NAME}\\s+id="(.+?)">(.*?)<\\/${MATCH_TAG_NAME}>`,
"gm" "gm"
); );
export default class Lookup { export default class Lookup {
@@ -778,10 +776,11 @@ function highlightQueries(
const regex = new RegExp(patterns.join("|"), "gi"); const regex = new RegExp(patterns.join("|"), "gi");
let hasMatches = false; let hasMatches = false;
let matchIdCounter = 0;
const result = text.replace(regex, (match) => { const result = text.replace(regex, (match) => {
hasMatches = true; hasMatches = true;
return `${MATCH_TAG_OPEN}${match}${MATCH_TAG_CLOSE}`; return createSearchResultTag(match, `match-${++matchIdCounter}`);
}); });
return { text: result, hasMatches }; return { text: result, hasMatches };
@@ -796,10 +795,11 @@ export function splitHighlightedMatch(text: string): Match[][] {
let matches: Match[] = []; let matches: Match[] = [];
let totalLength = 0; let totalLength = 0;
for (let i = 0; i < parts.length - 1; i += 2) { for (let i = 0; i < parts.length - 1; i += 3) {
const prefix = parts[i]; const prefix = parts[i];
const match = parts[i + 1]; const matchId = parts[i + 1];
let suffix = parts.at(i + 2); const match = parts[i + 2];
let suffix = parts[i + 3];
const matchLength = prefix.length + match.length + (suffix?.length || 0); const matchLength = prefix.length + match.length + (suffix?.length || 0);
if (totalLength > 120 && matches.length > 0) { if (totalLength > 120 && matches.length > 0) {
@@ -815,14 +815,15 @@ export function splitHighlightedMatch(text: string): Match[][] {
suffix, suffix,
Math.max(suffix.length / 2, 60) Math.max(suffix.length / 2, 60)
); );
parts[i + 2] = remaining; parts[i + 3] = remaining;
suffix = _suffix; suffix = _suffix;
} }
matches.push({ matches.push({
match, match,
prefix: prefix.replace(/\s{2,}/gm, " ").trimStart(), prefix: prefix.replace(/\s{2,}/gm, " ").trimStart(),
suffix: suffix || "" suffix: suffix || "",
id: matchId || undefined
}); });
totalLength += matchLength; totalLength += matchLength;
@@ -944,7 +945,8 @@ function stringToMatch(str: string): Match[] {
{ {
prefix: str, prefix: str,
match: "", match: "",
suffix: "" suffix: "",
id: undefined
} }
]; ];
} }
@@ -962,6 +964,7 @@ function highlightHtmlContent(html: string, queries: string[]): string {
const searchRegex = new RegExp(`(${patterns.join("|")})`, "gi"); const searchRegex = new RegExp(`(${patterns.join("|")})`, "gi");
let result = ""; let result = "";
let matchIdCounter = 0;
// Stack to track elements and their buffered content // Stack to track elements and their buffered content
interface ElementInfo { interface ElementInfo {
@@ -981,9 +984,8 @@ function highlightHtmlContent(html: string, queries: string[]): string {
// Reset regex state after test // Reset regex state after test
searchRegex.lastIndex = 0; searchRegex.lastIndex = 0;
const processed = text.replace( const processed = text.replace(searchRegex, (match) =>
searchRegex, createSearchResultTag(match, `match-${++matchIdCounter}`)
"<nn-search-result>$1</nn-search-result>"
); );
if (hasMatch) { if (hasMatch) {
@@ -1150,18 +1152,23 @@ function getMatchScore(
function textContainsTokens(text: string, tokens: QueryTokens) { function textContainsTokens(text: string, tokens: QueryTokens) {
const lowerCasedText = text.toLowerCase(); const lowerCasedText = text.toLowerCase();
const createTagPattern = (token: string) => {
return `<${MATCH_TAG_NAME}\\s+id="(.+?)">${token}<\\/${MATCH_TAG_NAME}>`;
};
if ( if (
!tokens.notTokens.every( !tokens.notTokens.every(
(t) => !lowerCasedText.includes(`${MATCH_TAG_OPEN}${t}${MATCH_TAG_CLOSE}`) (t) => !new RegExp(createTagPattern(t), "i").test(lowerCasedText)
) )
) )
return false; return false;
return ( return (
tokens.andTokens.every((t) => tokens.andTokens.every((t) =>
lowerCasedText.includes(`${MATCH_TAG_OPEN}${t}${MATCH_TAG_CLOSE}`) new RegExp(createTagPattern(t), "i").test(lowerCasedText)
) || ) ||
tokens.orTokens.some((t) => tokens.orTokens.some((t) =>
lowerCasedText.includes(`${MATCH_TAG_OPEN}${t}${MATCH_TAG_CLOSE}`) new RegExp(createTagPattern(t), "i").test(lowerCasedText)
) )
); );
} }
@@ -1198,3 +1205,7 @@ function transformTokens(tokens: QueryTokens | undefined) {
allTokens: [...andTokens, ...orTokens] allTokens: [...andTokens, ...orTokens]
}; };
} }
function createSearchResultTag(content: string, id: string) {
return `<${MATCH_TAG_NAME} id="${id}">${content}</${MATCH_TAG_NAME}>`;
}

View File

@@ -494,6 +494,7 @@ export type Match = {
prefix: string; prefix: string;
match: string; match: string;
suffix: string; suffix: string;
id?: string;
}; };
export interface HighlightedResult extends BaseItem<"searchResult"> { export interface HighlightedResult extends BaseItem<"searchResult"> {

View File

@@ -176,7 +176,7 @@ export function extractMatchingBlocks(html: string, matchTagName: string) {
const parser = new Parser( const parser = new Parser(
{ {
ontext: (data) => (text += data), ontext: (data) => (text += data),
onopentag(name) { onopentag(name, attributes) {
if (!INLINE_TAGS.includes(name) && name !== matchTagName) { if (!INLINE_TAGS.includes(name) && name !== matchTagName) {
openedTag = name; openedTag = name;
text = ""; text = "";
@@ -184,7 +184,12 @@ export function extractMatchingBlocks(html: string, matchTagName: string) {
} }
if (name === matchTagName) { if (name === matchTagName) {
hasMatches = true; hasMatches = true;
text += `<${name}>`; let tagString = `<${name}`;
if (attributes.id) {
tagString += ` id="${attributes.id}"`;
}
tagString += ">";
text += tagString;
} }
}, },
onclosetag(name) { onclosetag(name) {

View File

@@ -26,6 +26,18 @@ export const SearchResult = Mark.create({
return [{ tag: "nn-search-result" }]; return [{ tag: "nn-search-result" }];
}, },
addAttributes() {
return {
id: {
default: null,
parseHTML: (element) => element.getAttribute("id"),
renderHTML: (attributes) => {
return attributes.id ? { id: attributes.id } : {};
}
}
};
},
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
return [ return [
"nn-search-result", "nn-search-result",