Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add highlight search terms in note feed when searching #103 #111

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@
"react-intersection-observer": "^9.10.3",
"react-resizable-panels": "^2.0.19",
"rehype-sanitize": "^6.0.0",
"remark": "^15.0.1",
"sonner": "^1.5.0",
"strip-markdown": "^6.0.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8",
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/components/notes/NoteCard.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Note } from "&/github.com/nodetec/captains-log/db/models";
import { parseContent } from "~/lib/markdown";
import { cn, fromNow } from "~/lib/utils";
import { useAppState } from "~/store";

import { Separator } from "../ui/separator";
import NoteCardPreview from "./NoteCardPreview";

type Props = {
note: Note;
Expand All @@ -22,6 +22,7 @@ export default function NoteCard({ note }: Props) {
event.preventDefault();
console.log("Right Clicked");
};

return (
<div className="mx-3 flex w-full flex-col items-center">
<button
Expand All @@ -42,11 +43,11 @@ export default function NoteCard({ note }: Props) {
}
>
<div className="flex w-full flex-col gap-1.5">
<h2 className="select-none truncate line-clamp-1 break-all whitespace-break-spaces text-ellipsis font-semibold text-primary">
<h2 className="line-clamp-1 select-none truncate text-ellipsis whitespace-break-spaces break-all font-semibold text-primary">
{note.Title}
</h2>
<div className="mt-0 line-clamp-2 text-ellipsis whitespace-break-spaces break-all pt-0 text-muted-foreground">
{parseContent(note.Content) || "No content \n "}
<NoteCardPreview note={note} />
</div>
<span className="select-none text-xs text-muted-foreground/80">
{note.ModifiedAt && fromNow(note.ModifiedAt)}
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/components/notes/NoteCardPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useEffect, useState } from "react";

import { Note } from "&/github.com/nodetec/captains-log/db/models";
import { parseContent } from "~/lib/markdown";
import { useAppState } from "~/store";

type Props = {
note: Note;
};

export default function NoteCardPreview({ note }: Props) {
const noteSearch = useAppState((state) => state.noteSearch);
const [parsedContent, setParsedContent] = useState("");

useEffect(() => {
const getParsedContent = async () => {
const parsedContentResult =
(await parseContent(note.Content, noteSearch)) || "No content \n ";
setParsedContent(parsedContentResult);
};

getParsedContent();
}, [noteSearch]);

return <div dangerouslySetInnerHTML={{ __html: parsedContent }}></div>;
}
2 changes: 1 addition & 1 deletion frontend/src/components/notes/SearchNotes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default function SearchNotes() {
<div className="flex items-center px-3 pb-4 pt-2">
<Input
placeholder="Search..."
className="text-muted-foreground/80 h-8 placeholder:text-muted-foreground/60 focus-visible:ring-primary"
className="h-8 text-muted-foreground/80 placeholder:text-muted-foreground/60 focus-visible:ring-primary"
onChange={handleSetSearchNote}
value={noteSearch}
/>
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/components/notes/TrashNoteCard.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { Trash } from "&/github.com/nodetec/captains-log/db/models";
import { parseContent } from "~/lib/markdown";
import { cn, fromNow } from "~/lib/utils";
import { useAppState } from "~/store";

import { Separator } from "../ui/separator";
import TrashNoteCardPreview from "./TrashNoteCardPreview";

type Props = {
trashNote: Trash;
};

export default function TrashNoteCard({ trashNote }: Props) {
const setActiveTrashNote = useAppState((state) => state.setActiveTrashNote);
const activeTrashNote = useAppState((state) => state.activeTrashNote);
const setActiveTrashNote = useAppState((state) => state.setActiveTrashNote);

function handleSetActiveNote(event: React.MouseEvent<HTMLDivElement>) {
event.preventDefault();
Expand All @@ -27,8 +27,8 @@ export default function TrashNoteCard({ trashNote }: Props) {
)}
>
<div
onClick={handleSetActiveNote}
className="flex w-full flex-col gap-1"
onClick={handleSetActiveNote}
style={
{
"--custom-contextmenu": "trashNoteMenu",
Expand All @@ -37,11 +37,11 @@ export default function TrashNoteCard({ trashNote }: Props) {
}
>
<div className="flex w-full flex-col gap-1.5">
<h2 className="select-none truncate line-clamp-1 break-all whitespace-break-spaces text-ellipsis font-semibold text-primary">
<h2 className="line-clamp-1 select-none truncate text-ellipsis whitespace-break-spaces break-all font-semibold text-primary">
{trashNote.Title}
</h2>
<div className="mt-0 line-clamp-2 text-ellipsis whitespace-break-spaces break-all pt-0 text-muted-foreground">
{parseContent(trashNote.Content) || "No content \n "}
<TrashNoteCardPreview trashNote={trashNote} />
</div>
<span className="select-none text-xs text-muted-foreground/80">
{trashNote.ModifiedAt && fromNow(trashNote.ModifiedAt)}
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/components/notes/TrashNoteCardPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useEffect, useState } from "react";

import { Trash } from "&/github.com/nodetec/captains-log/db/models";
import { parseContent } from "~/lib/markdown";
import { useAppState } from "~/store";

type Props = {
trashNote: Trash;
};

export default function TrashNoteCardPreview({ trashNote }: Props) {
const noteSearch = useAppState((state) => state.noteSearch);
const [parsedContent, setParsedContent] = useState("");

useEffect(() => {
const getParsedContent = async () => {
const parsedContentResult =
(await parseContent(trashNote.Content, noteSearch)) || "No content \n ";
setParsedContent(parsedContentResult);
};

getParsedContent();
}, [noteSearch]);

return <div dangerouslySetInnerHTML={{ __html: parsedContent }}></div>;
}
2 changes: 0 additions & 2 deletions frontend/src/components/notes/TrashSearchFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@ export default function TrashSearchFeed() {
sortDirection,
);

console.log("search trash",notes);

return {
data: notes || [],
nextPage: pageParam + 1,
Expand Down
118 changes: 102 additions & 16 deletions frontend/src/lib/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import dayjs from "dayjs";
import { remark } from "remark";
import strip from "strip-markdown";

interface NoteSearchMatchIndices {
start: number;
end: number;
}

export function parseTitle(markdownContent: string) {
// Split the content into lines
Expand All @@ -10,8 +17,6 @@ export function parseTitle(markdownContent: string) {
// Check if the first line is a header
const headerMatch = firstLine.match(/^#+\s+(.*)$/);

console.log("headerMatch", headerMatch);

if (headerMatch) {
// Extract and return the title without the header markdown
return headerMatch[1];
Expand All @@ -21,24 +26,105 @@ export function parseTitle(markdownContent: string) {
return dayjs().format("YYYY-MM-DD");
}

export function parseContent(markdownContent: string) {
// Split the content into lines
const lines = markdownContent.split("\n");
async function stripMarkdown(content: string) {
const strippedMdContentVFile = await remark().use(strip).process(content);
const strippedMdContent = String(strippedMdContentVFile);

return strippedMdContent;
}

function addSpanTags(
content: string,
searchMatchIndices: NoteSearchMatchIndices[],
) {
const spanTagOpen = `<span class='bg-primary text-primary-foreground rounded-sm'>`;
const spanTagOpenLength = spanTagOpen.length;

// Check if the first line is a header
const firstLine = lines[0].trim();
const isHeader = /^#+\s+(.*)$/.test(firstLine);
const spanTagClose = `</span>`;
const spanTagCloseLength = spanTagClose.length;

// Determine the starting index for processing lines
const startIndex = isHeader ? 1 : 0;
const spanTagLength = spanTagOpenLength + spanTagCloseLength;

// Filter out empty lines, ignoring the title if present
const filteredLines = lines
.slice(startIndex) // Start from the second line if the first line is a header
.filter((line) => line.trim() !== "");
let numberOfSpanTagsAdded = 0;
searchMatchIndices.forEach((match) => {
content =
content.substring(
0,
match.start + numberOfSpanTagsAdded * spanTagLength,
) +
spanTagOpen +
content.substring(match.start + numberOfSpanTagsAdded * spanTagLength);

// Join the lines back together with newlines
const content = filteredLines.join("\n");
content =
content.substring(
0,
match.end +
1 +
spanTagOpenLength +
numberOfSpanTagsAdded * spanTagLength,
) +
spanTagClose +
content.substring(
match.end +
1 +
spanTagOpenLength +
numberOfSpanTagsAdded * spanTagLength,
);

numberOfSpanTagsAdded = numberOfSpanTagsAdded + 1;
});
return content;
}

export async function parseContent(
markdownContent: string,
noteSearch: string,
) {
// Split the content into lines
const lines = markdownContent.split("\n");

// Check if the first line is a header
const firstLine = lines[0].trim();
const isHeader = /^#+\s+(.*)$/.test(firstLine);

// Determine the starting index for processing lines
const startIndex = isHeader ? 1 : 0;

// Filter out empty lines, ignoring the title if present
const filteredLines = lines
.slice(startIndex) // Start from the second line if the first line is a header
.filter((line) => line.trim() !== "");

// Join the lines back together with newlines
const content = filteredLines.join("\n");

try {
const strippedMdContent = await stripMarkdown(content);
const searchMatchIndices: NoteSearchMatchIndices[] = [];
let matchIndexStart = 0;
let step = 0;
if (noteSearch !== "") {
while (step < strippedMdContent.length) {
matchIndexStart = strippedMdContent.indexOf(noteSearch, step);
if (matchIndexStart === -1) {
break;
}

step = matchIndexStart + noteSearch.length;

searchMatchIndices.push({
start: matchIndexStart,
end: step - 1,
});
}
}
const contentWithSpanTags = addSpanTags(
strippedMdContent,
searchMatchIndices,
);

return contentWithSpanTags;
} catch {
return content;
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ require (
github.com/sergi/go-diff v1.2.0 // indirect
github.com/sethvargo/go-retry v0.2.4 // indirect
github.com/skeema/knownhosts v1.2.1 // indirect
github.com/wailsapp/go-webview2 v1.0.11 // indirect
github.com/wailsapp/go-webview2 v1.0.15 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
go.uber.org/multierr v1.11.0 // indirect
Expand Down