mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
feat: highlight the searched text in memo content (#514)
* feat: highlight the searched text in memo content * update * update * update * update Co-authored-by: boojack <stevenlgtm@gmail.com>
This commit is contained in:
@ -19,6 +19,7 @@ dayjs.extend(relativeTime);
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
memo: Memo;
|
memo: Memo;
|
||||||
|
highlightWord?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFormatedMemoTimeStr = (time: number, locale = "en"): string => {
|
export const getFormatedMemoTimeStr = (time: number, locale = "en"): string => {
|
||||||
@ -30,7 +31,7 @@ export const getFormatedMemoTimeStr = (time: number, locale = "en"): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Memo: React.FC<Props> = (props: Props) => {
|
const Memo: React.FC<Props> = (props: Props) => {
|
||||||
const memo = props.memo;
|
const { memo, highlightWord } = props;
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [displayTimeStr, setDisplayTimeStr] = useState<string>(getFormatedMemoTimeStr(memo.displayTs, i18n.language));
|
const [displayTimeStr, setDisplayTimeStr] = useState<string>(getFormatedMemoTimeStr(memo.displayTs, i18n.language));
|
||||||
@ -239,6 +240,7 @@ const Memo: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<MemoContent
|
<MemoContent
|
||||||
content={memo.content}
|
content={memo.content}
|
||||||
|
highlightWord={highlightWord}
|
||||||
onMemoContentClick={handleMemoContentClick}
|
onMemoContentClick={handleMemoContentClick}
|
||||||
onMemoContentDoubleClick={handleMemoContentDoubleClick}
|
onMemoContentDoubleClick={handleMemoContentDoubleClick}
|
||||||
/>
|
/>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { marked } from "../labs/marked";
|
import { marked } from "../labs/marked";
|
||||||
|
import { highlightWithWord } from "../labs/highlighter";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import { SETTING_IS_FOLDING_ENABLED_KEY, IS_FOLDING_ENABLED_DEFAULT_VALUE } from "../helpers/consts";
|
import { SETTING_IS_FOLDING_ENABLED_KEY, IS_FOLDING_ENABLED_DEFAULT_VALUE } from "../helpers/consts";
|
||||||
import useLocalStorage from "../hooks/useLocalStorage";
|
import useLocalStorage from "../hooks/useLocalStorage";
|
||||||
@ -12,6 +13,7 @@ export interface DisplayConfig {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: string;
|
content: string;
|
||||||
|
highlightWord?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
displayConfig?: Partial<DisplayConfig>;
|
displayConfig?: Partial<DisplayConfig>;
|
||||||
onMemoContentClick?: (e: React.MouseEvent) => void;
|
onMemoContentClick?: (e: React.MouseEvent) => void;
|
||||||
@ -29,7 +31,7 @@ const defaultDisplayConfig: DisplayConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MemoContent: React.FC<Props> = (props: Props) => {
|
const MemoContent: React.FC<Props> = (props: Props) => {
|
||||||
const { className, content, onMemoContentClick, onMemoContentDoubleClick } = props;
|
const { className, content, highlightWord, onMemoContentClick, onMemoContentDoubleClick } = props;
|
||||||
const foldedContent = useMemo(() => {
|
const foldedContent = useMemo(() => {
|
||||||
const firstHorizontalRuleIndex = content.search(/^---$|^\*\*\*$|^___$/m);
|
const firstHorizontalRuleIndex = content.search(/^---$|^\*\*\*$|^___$/m);
|
||||||
return firstHorizontalRuleIndex !== -1 ? content.slice(0, firstHorizontalRuleIndex) : content;
|
return firstHorizontalRuleIndex !== -1 ? content.slice(0, firstHorizontalRuleIndex) : content;
|
||||||
@ -86,7 +88,9 @@ const MemoContent: React.FC<Props> = (props: Props) => {
|
|||||||
className={`memo-content-text ${state.expandButtonStatus === 0 ? "expanded" : ""}`}
|
className={`memo-content-text ${state.expandButtonStatus === 0 ? "expanded" : ""}`}
|
||||||
onClick={handleMemoContentClick}
|
onClick={handleMemoContentClick}
|
||||||
onDoubleClick={handleMemoContentDoubleClick}
|
onDoubleClick={handleMemoContentDoubleClick}
|
||||||
dangerouslySetInnerHTML={{ __html: marked(state.expandButtonStatus === 0 ? foldedContent : content) }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: highlightWithWord(marked(state.expandButtonStatus === 0 ? foldedContent : content), highlightWord),
|
||||||
|
}}
|
||||||
></div>
|
></div>
|
||||||
{state.expandButtonStatus !== -1 && (
|
{state.expandButtonStatus !== -1 && (
|
||||||
<div className="expand-btn-container">
|
<div className="expand-btn-container">
|
||||||
|
@ -16,6 +16,7 @@ const MemoList = () => {
|
|||||||
const memoDisplayTsOption = useAppSelector((state) => state.user.user?.setting.memoDisplayTsOption);
|
const memoDisplayTsOption = useAppSelector((state) => state.user.user?.setting.memoDisplayTsOption);
|
||||||
const { memos, isFetching } = useAppSelector((state) => state.memo);
|
const { memos, isFetching } = useAppSelector((state) => state.memo);
|
||||||
const [isComplete, setIsComplete] = useState<boolean>(false);
|
const [isComplete, setIsComplete] = useState<boolean>(false);
|
||||||
|
const [highlightWord, setHighlightWord] = useState<string | undefined>("");
|
||||||
|
|
||||||
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId, visibility } = query ?? {};
|
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId, visibility } = query ?? {};
|
||||||
const shortcut = shortcutId ? shortcutService.getShortcutById(shortcutId) : null;
|
const shortcut = shortcutId ? shortcutService.getShortcutById(shortcutId) : null;
|
||||||
@ -103,6 +104,7 @@ const MemoList = () => {
|
|||||||
if (pageWrapper) {
|
if (pageWrapper) {
|
||||||
pageWrapper.scrollTo(0, 0);
|
pageWrapper.scrollTo(0, 0);
|
||||||
}
|
}
|
||||||
|
setHighlightWord(query?.text);
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -131,7 +133,7 @@ const MemoList = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="memo-list-container">
|
<div className="memo-list-container">
|
||||||
{sortedMemos.map((memo) => (
|
{sortedMemos.map((memo) => (
|
||||||
<Memo key={`${memo.id}-${memo.displayTs}`} memo={memo} />
|
<Memo key={`${memo.id}-${memo.displayTs}`} memo={memo} highlightWord={highlightWord} />
|
||||||
))}
|
))}
|
||||||
{isFetching ? (
|
{isFetching ? (
|
||||||
<div className="status-text-container fetching-tip">
|
<div className="status-text-container fetching-tip">
|
||||||
|
26
web/src/labs/highlighter/index.ts
Normal file
26
web/src/labs/highlighter/index.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
const escapeRegExp = (str: string): string => {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
};
|
||||||
|
|
||||||
|
const walkthroughNodeWithKeyword = (node: HTMLElement, keyword: string) => {
|
||||||
|
if (node.nodeType === 3) {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.innerHTML = node.nodeValue?.replace(new RegExp(keyword, "g"), `<mark>${keyword}</mark>`) ?? "";
|
||||||
|
node.parentNode?.insertBefore(span, node);
|
||||||
|
node.parentNode?.removeChild(node);
|
||||||
|
}
|
||||||
|
for (const child of Array.from(node.childNodes)) {
|
||||||
|
walkthroughNodeWithKeyword(<HTMLElement>child, keyword);
|
||||||
|
}
|
||||||
|
return node.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const highlightWithWord = (html: string, keyword?: string): string => {
|
||||||
|
if (!keyword) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
keyword = escapeRegExp(keyword);
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.innerHTML = html;
|
||||||
|
return walkthroughNodeWithKeyword(wrap, keyword);
|
||||||
|
};
|
Reference in New Issue
Block a user