diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index 77fec1366..4b425f832 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -448,7 +448,7 @@ function sleep(time_ms: number) { }); } -function escapeRegExp(str: string) { +export function escapeRegExp(str: string) { return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); } diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index d749e2844..173ec06be 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -6,30 +6,31 @@ import { ListView, GridView } from "./legacy/ListView"; import { useEffect, useState } from "preact/hooks"; interface NoteListProps { + note?: FNote | null; displayOnlyCollections?: boolean; + highlightedTokens?: string[] | null; } -export default function NoteList({ }: NoteListProps) { - const { note } = useNoteContext(); +export default function NoteList({ note: providedNote, highlightedTokens }: NoteListProps) { + const { note: contextNote } = useNoteContext(); + const note = providedNote ?? contextNote; const viewType = useNoteViewType(note); const noteIds = useNoteIds(note, viewType); const isEnabled = (note && !!viewType); - // Refresh note Ids - return (
{isEnabled && (
- {getComponentByViewType(note, noteIds, viewType)} + {getComponentByViewType(note, noteIds, viewType, highlightedTokens)}
)}
); } -function getComponentByViewType(note: FNote, noteIds: string[], viewType: ViewTypeOptions) { - const props: ViewModeProps = { note, noteIds }; +function getComponentByViewType(note: FNote, noteIds: string[], viewType: ViewTypeOptions, highlightedTokens: string[] | null | undefined) { + const props: ViewModeProps = { note, noteIds, highlightedTokens }; switch (viewType) { case "list": diff --git a/apps/client/src/widgets/collections/interface.ts b/apps/client/src/widgets/collections/interface.ts index 4c3c71e76..80c194f31 100644 --- a/apps/client/src/widgets/collections/interface.ts +++ b/apps/client/src/widgets/collections/interface.ts @@ -11,4 +11,5 @@ export interface ViewModeProps { * We're using noteIds so that it's not necessary to load all notes at once when paging. */ noteIds: string[]; + highlightedTokens: string[] | null | undefined; } diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListView.tsx index 1bf98c3de..62f6505fd 100644 --- a/apps/client/src/widgets/collections/legacy/ListView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListView.tsx @@ -12,7 +12,7 @@ import link from "../../../services/link"; import { t } from "../../../services/i18n"; import attribute_renderer from "../../../services/attribute_renderer"; -export function ListView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { +export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps) { const [ isExpanded ] = useNoteLabelBoolean(note, "expanded"); const noteIds = useFilteredNoteIds(note, unfilteredNoteIds); const { pageNotes, ...pagination } = usePagination(note, noteIds); @@ -24,7 +24,7 @@ export function ListView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { @@ -34,7 +34,7 @@ export function ListView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { ); } -export function GridView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { +export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps) { const noteIds = useFilteredNoteIds(note, unfilteredNoteIds); const { pageNotes, ...pagination } = usePagination(note, noteIds); @@ -45,7 +45,7 @@ export function GridView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { @@ -55,7 +55,7 @@ export function GridView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { ); } -function ListNoteCard({ note, parentNote, expand }: { note: FNote, parentNote: FNote, expand?: boolean }) { +function ListNoteCard({ note, parentNote, expand, highlightedTokens }: { note: FNote, parentNote: FNote, expand?: boolean, highlightedTokens: string[] | null | undefined }) { const [ isExpanded, setExpanded ] = useState(expand); const notePath = getNotePath(parentNote, note); @@ -71,7 +71,7 @@ function ListNoteCard({ note, parentNote, expand }: { note: FNote, parentNote: F /> - + {isExpanded && <> @@ -83,7 +83,8 @@ function ListNoteCard({ note, parentNote, expand }: { note: FNote, parentNote: F ) } -function GridNoteCard({ note, parentNote }: { note: FNote, parentNote: FNote }) { +function GridNoteCard({ note, parentNote, highlightedTokens }: { note: FNote, parentNote: FNote, highlightedTokens: string[] | null | undefined }) { + const titleRef = useRef(null); const [ noteTitle, setNoteTitle ] = useState(); const notePath = getNotePath(parentNote, note); @@ -100,7 +101,7 @@ function GridNoteCard({ note, parentNote }: { note: FNote, parentNote: FNote }) >
- {noteTitle} + {noteTitle}
@@ -149,7 +150,7 @@ function NoteChildren({ note, parentNote }: { note: FNote, parentNote: FNote }) }); }, [ note ]); - return childNotes?.map(childNote => ) + return childNotes?.map(childNote => ) } /** diff --git a/apps/client/src/widgets/react/NoteLink.tsx b/apps/client/src/widgets/react/NoteLink.tsx index 09ed254d8..4d7925bf7 100644 --- a/apps/client/src/widgets/react/NoteLink.tsx +++ b/apps/client/src/widgets/react/NoteLink.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import link from "../../services/link"; import RawHtml from "./RawHtml"; +import { useSearchHighlighlighting } from "./hooks"; interface NoteLinkOpts { className?: string; @@ -10,11 +11,14 @@ interface NoteLinkOpts { style?: Record; noPreview?: boolean; noTnLink?: boolean; + highlightedTokens?: string[] | null | undefined; } -export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink }: NoteLinkOpts) { +export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens }: NoteLinkOpts) { const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath; const [ jqueryEl, setJqueryEl ] = useState>(); + const containerRef = useRef(null); + useSearchHighlighlighting(containerRef, highlightedTokens); useEffect(() => { link.createLink(stringifiedNotePath, { showNotePath, showNoteIcon }) @@ -38,6 +42,6 @@ export default function NoteLink({ className, notePath, showNotePath, showNoteIc $linkEl?.addClass(className); } - return - -} \ No newline at end of file + return + +} diff --git a/apps/client/src/widgets/react/RawHtml.tsx b/apps/client/src/widgets/react/RawHtml.tsx index a8b3b2820..e022b5480 100644 --- a/apps/client/src/widgets/react/RawHtml.tsx +++ b/apps/client/src/widgets/react/RawHtml.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties } from "preact/compat"; +import type { CSSProperties, RefObject } from "preact/compat"; type HTMLElementLike = string | HTMLElement | JQuery; @@ -9,12 +9,12 @@ interface RawHtmlProps { onClick?: (e: MouseEvent) => void; } -export default function RawHtml(props: RawHtmlProps) { - return ; +export default function RawHtml({containerRef, ...props}: RawHtmlProps & { containerRef?: RefObject}) { + return ; } -export function RawHtmlBlock(props: RawHtmlProps) { - return
+export function RawHtmlBlock({containerRef, ...props}: RawHtmlProps & { containerRef?: RefObject}) { + return
} function getProps({ className, html, style, onClick }: RawHtmlProps) { @@ -38,4 +38,4 @@ export function getHtml(html: string | HTMLElement | JQuery) { return { __html: html as string }; -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 523f3ef77..fa6c901bc 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -4,7 +4,7 @@ import { ParentComponent } from "./react_utils"; import SpacedUpdate from "../../services/spaced_update"; import { KeyboardActionNames, OptionNames } from "@triliumnext/commons"; import options, { type OptionValue } from "../../services/options"; -import utils, { reloadFrontendApp } from "../../services/utils"; +import utils, { escapeRegExp, reloadFrontendApp } from "../../services/utils"; import NoteContext from "../../components/note_context"; import BasicWidget, { ReactWrappedWidget } from "../basic_widget"; import FNote from "../../entities/fnote"; @@ -15,6 +15,7 @@ import { RefObject, VNode } from "preact"; import { Tooltip } from "bootstrap"; import { CSSProperties } from "preact/compat"; import keyboard_actions from "../../services/keyboard_actions"; +import Mark from "mark.js"; export function useTriliumEvent(eventName: T, handler: (data: EventData) => void) { const parentComponent = useContext(ParentComponent); @@ -27,7 +28,7 @@ export function useTriliumEvent(eventName: T, handler: (da export function useTriliumEvents(eventNames: T[], handler: (data: EventData, eventName: T) => void) { const parentComponent = useContext(ParentComponent); - + useLayoutEffect(() => { const handlers: ({ eventName: T, callback: (data: EventData) => void })[] = []; for (const eventName of eventNames) { @@ -35,11 +36,11 @@ export function useTriliumEvents(eventNames: T[], handler: handler(data, eventName); }}) } - + for (const { eventName, callback } of handlers) { parentComponent?.registerHandler(eventName, callback); } - + return (() => { for (const { eventName, callback } of handlers) { parentComponent?.removeHandler(eventName, callback); @@ -76,10 +77,10 @@ export function useSpacedUpdate(callback: () => void | Promise, interval = /** * Allows a React component to read and write a Trilium option, while also watching for external changes. - * + * * Conceptually, `useTriliumOption` works just like `useState`, but the value is also automatically updated if * the option is changed somewhere else in the client. - * + * * @param name the name of the option to listen for. * @param needsRefresh whether to reload the frontend whenever the value is changed. * @returns an array where the first value is the current option value and the second value is the setter. @@ -115,7 +116,7 @@ export function useTriliumOption(name: OptionNames, needsRefresh?: boolean): [st /** * Similar to {@link useTriliumOption}, but the value is converted to and from a boolean instead of a string. - * + * * @param name the name of the option to listen for. * @param needsRefresh whether to reload the frontend whenever the value is changed. * @returns an array where the first value is the current option value and the second value is the setter. @@ -131,7 +132,7 @@ export function useTriliumOptionBool(name: OptionNames, needsRefresh?: boolean): /** * Similar to {@link useTriliumOption}, but the value is converted to and from a int instead of a string. - * + * * @param name the name of the option to listen for. * @param needsRefresh whether to reload the frontend whenever the value is changed. * @returns an array where the first value is the current option value and the second value is the setter. @@ -147,7 +148,7 @@ export function useTriliumOptionInt(name: OptionNames): [number, (newValue: numb /** * Similar to {@link useTriliumOption}, but the object value is parsed to and from a JSON instead of a string. - * + * * @param name the name of the option to listen for. * @returns an array where the first value is the current option value and the second value is the setter. */ @@ -161,8 +162,8 @@ export function useTriliumOptionJson(name: OptionNames): [ T, (newValue: T) = } /** - * Similar to {@link useTriliumOption}, but operates with multiple options at once. - * + * Similar to {@link useTriliumOption}, but operates with multiple options at once. + * * @param names the name of the option to listen for. * @returns an array where the first value is a map where the keys are the option names and the values, and the second value is the setter which takes in the same type of map and saves them all at once. */ @@ -182,10 +183,10 @@ export function useTriliumOptions(...names: T[]) { /** * Generates a unique name via a random alphanumeric string of a fixed length. - * + * *

* Generally used to assign names to inputs that are unique, especially useful for widgets inside tabs. - * + * * @param prefix a prefix to add to the unique name. * @returns a name with the given prefix and a random alpanumeric string appended to it. */ @@ -196,7 +197,7 @@ export function useUniqueName(prefix?: string) { export function useNoteContext() { const [ noteContext, setNoteContext ] = useState(); const [ notePath, setNotePath ] = useState(); - const [ note, setNote ] = useState(); + const [ note, setNote ] = useState(); const [ refreshCounter, setRefreshCounter ] = useState(0); useEffect(() => { @@ -205,7 +206,7 @@ export function useNoteContext() { useTriliumEvents([ "setNoteContext", "activeContextChanged", "noteSwitchedAndActivated", "noteSwitched" ], ({ noteContext }) => { setNoteContext(noteContext); - setNotePath(noteContext.notePath); + setNotePath(noteContext.notePath); }); useTriliumEvent("frocaReloaded", () => { setNote(noteContext?.note); @@ -235,7 +236,7 @@ export function useNoteContext() { /** * Allows a React component to listen to obtain a property of a {@link FNote} while also automatically watching for changes, either via the user changing to a different note or the property being changed externally. - * + * * @param note the {@link FNote} whose property to obtain. * @param property a property of a {@link FNote} to obtain the value from (e.g. `title`, `isProtected`). * @param componentId optionally, constricts the refresh of the value if an update occurs externally via the component ID of a legacy widget. This can be used to avoid external data replacing fresher, user-inputted data. @@ -287,7 +288,7 @@ export function useNoteRelation(note: FNote | undefined | null, relationName: st /** * Allows a React component to read or write a note's label while also reacting to changes in value. - * + * * @param note the note whose label to read/write. * @param labelName the name of the label to read/write. * @returns an array where the first element is the getter and the second element is the setter. The setter has a special behaviour for convenience: if the value is undefined, the label is created without a value (e.g. a tag), if the value is null then the label is removed. @@ -352,9 +353,9 @@ export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: s export function useNoteBlob(note: FNote | null | undefined): [ FBlob | null | undefined ] { const [ blob, setBlob ] = useState(); - + function refresh() { - note?.getBlob().then(setBlob); + note?.getBlob().then(setBlob); } useEffect(refresh, [ note?.noteId ]); @@ -388,7 +389,7 @@ export function useLegacyWidget(widgetFactory: () => T, { if (noteContext && widget instanceof NoteContextAwareWidget) { widget.setNoteContextEvent({ noteContext }); } - + const renderedWidget = widget.render(); return [ widget, renderedWidget ]; }, []); @@ -415,7 +416,7 @@ export function useLegacyWidget(widgetFactory: () => T, { /** * Attaches a {@link ResizeObserver} to the given ref and reads the bounding client rect whenever it changes. - * + * * @param ref a ref to a {@link HTMLElement} to determine the size and observe the changes in size. * @returns the size of the element, reacting to changes. */ @@ -445,7 +446,7 @@ export function useElementSize(ref: RefObject) { /** * Obtains the inner width and height of the window, as well as reacts to changes in size. - * + * * @returns the width and height of the window. */ export function useWindowSize() { @@ -453,7 +454,7 @@ export function useWindowSize() { windowWidth: window.innerWidth, windowHeight: window.innerHeight }); - + useEffect(() => { function onResize() { setSize({ @@ -499,7 +500,7 @@ export function useTooltip(elRef: RefObject, config: Partial(externalRef?: RefObject, initialValue: T | nu }, [ ref, externalRef ]); return ref; -} \ No newline at end of file +} + +export function useSearchHighlighlighting(ref: RefObject, highlightedTokens: string[] | null | undefined) { + const mark = useRef(); + const highlightRegex = useMemo(() => { + if (!highlightedTokens?.length) return null; + const regex = highlightedTokens.map((token) => escapeRegExp(token)).join("|"); + return new RegExp(regex, "gi") + }, [ highlightedTokens ]); + + useEffect(() => { + if (!ref.current || !highlightRegex) return; + + if (!mark.current) { + mark.current = new Mark(ref.current); + } + + mark.current.markRegExp(highlightRegex, { + element: "span", + className: "ck-find-result" + }); + + return () => mark.current?.unmark(); + }); +} diff --git a/apps/client/src/widgets/search_result.tsx b/apps/client/src/widgets/search_result.tsx index adfb1b0a6..abe9d4174 100644 --- a/apps/client/src/widgets/search_result.tsx +++ b/apps/client/src/widgets/search_result.tsx @@ -1,11 +1,12 @@ -import { useEffect, useRef, useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { t } from "../services/i18n"; import Alert from "./react/Alert"; -import { useNoteContext, useNoteProperty, useTriliumEvent } from "./react/hooks"; +import { useNoteContext, useTriliumEvent } from "./react/hooks"; import "./search_result.css"; +import NoteList from "./collections/NoteList"; // import NoteListRenderer from "../services/note_list_renderer"; -enum SearchResultState { +enum SearchResultState { NO_RESULTS, NOT_EXECUTED, GOT_RESULTS @@ -14,27 +15,18 @@ enum SearchResultState { export default function SearchResult() { const { note, ntxId } = useNoteContext(); const [ state, setState ] = useState(); - const searchContainerRef = useRef(null); + const [ highlightedTokens, setHighlightedTokens ] = useState(); function refresh() { - searchContainerRef.current?.replaceChildren(); - if (note?.type !== "search") { setState(undefined); } else if (!note?.searchResultsLoaded) { setState(SearchResultState.NOT_EXECUTED); } else if (note.getChildNoteIds().length === 0) { setState(SearchResultState.NO_RESULTS); - } else if (searchContainerRef.current) { + } else { setState(SearchResultState.GOT_RESULTS); - - // TODO: Fix me. - // const noteListRenderer = new NoteListRenderer({ - // $parent: $(searchContainerRef.current), - // parentNote: note, - // showNotePath: true - // }); - // noteListRenderer.renderList(); + setHighlightedTokens(note.highlightedTokens); } } @@ -60,7 +52,9 @@ export default function SearchResult() { {t("search_result.no_notes_found")} )} -

+ {state === SearchResultState.GOT_RESULTS && ( + + )}
); -} \ No newline at end of file +}