From 03ea89210937ac2a0b14af867578fba23aa2d228 Mon Sep 17 00:00:00 2001 From: Roman Date: Sun, 22 Mar 2026 20:44:30 +0000 Subject: [PATCH 1/9] feat: implement find and replace functionality --- package.json | 4 +- src/components/App.tsx | 97 +++++++++++++- src/components/Editor.tsx | 137 +++++++++++++++++-- src/components/FindReplaceModal.tsx | 201 ++++++++++++++++++++++++++++ src/components/Toolbar.tsx | 23 +++- src/lib/get-matches.ts | 16 +++ tests/get-matches.lib.test.ts | 8 ++ 7 files changed, 466 insertions(+), 20 deletions(-) create mode 100644 src/components/FindReplaceModal.tsx create mode 100644 src/lib/get-matches.ts create mode 100644 tests/get-matches.lib.test.ts diff --git a/package.json b/package.json index 63e9b0a..2980e86 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,11 @@ "#styles": "./src/styles/global.css", "#layout": "./src/layouts/Layout.astro", "#app": "./src/components/App.tsx", - "#hooks/*": "./src/hooks/*.ts" + "#hooks/*": "./src/hooks/*.ts", + "#lib/*": "./src/lib/*.ts" }, "scripts": { + "test": "bun test", "lint": "biome check", "format": "biome check --fix --linter-enabled=false", "build": "astro build", diff --git a/src/components/App.tsx b/src/components/App.tsx index 2aed598..ba305be 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,8 +1,11 @@ -import { createSignal } from "solid-js"; +import { createMemo, createSignal } from "solid-js"; import { usePages } from "#hooks/usePages"; import { useEditorSettings } from "#hooks/useSettings"; +import { getMatches } from "#lib/get-matches"; import Editor from "./Editor"; +import type { Direction } from "./FindReplaceModal"; +import FindReplaceModal from "./FindReplaceModal"; import PagesMenu from "./PagesMenu"; import SettingsModal from "./SettingsModal"; import Toolbar from "./Toolbar"; @@ -22,6 +25,10 @@ function App() { const [isSettingsOpen, setIsSettingsOpen] = createSignal(false); const [isPagesMenuOpen, setIsPagesMenuOpen] = createSignal(false); + const [isSearchOpen, setIsSearchOpen] = createSignal(false); + + const [searchTerm, setSearchTerm] = createSignal(""); + const [currentMatchIndex, setCurrentMatchIndex] = createSignal(0); const [toolbarOpacity, setToolbarOpacity] = createSignal(1); @@ -29,6 +36,70 @@ function App() { const PAGES_BROKEN = ""; + const content = () => currentPage()?.content ?? ""; + + const matches = createMemo(() => getMatches(content(), searchTerm())); + + const handleNavigate = (direction: Direction) => { + const matchCount = matches().length; + if (matchCount === 0) return; + + let newIndex = currentMatchIndex(); + if (direction === "next") { + newIndex = (newIndex + 1) % matchCount; + } else { + newIndex = (newIndex - 1 + matchCount) % matchCount; + } + setCurrentMatchIndex(newIndex); + }; + + const handleReplace = (replacement: string) => { + const matchPositions = matches(); + if (matchPositions.length === 0 || !searchTerm()) return; + + const idx = currentMatchIndex(); + const pos = matchPositions[idx]; + if (pos === undefined) return; + + const text = content(); + const newContent = + text.slice(0, pos) + replacement + text.slice(pos + searchTerm().length); + updatePageContent(newContent); + + const newMatches = getMatches(newContent, searchTerm()); + if (newMatches.length > 0) { + setCurrentMatchIndex(Math.min(idx, newMatches.length - 1)); + } + }; + + const handleReplaceAll = (replacement: string) => { + if (!searchTerm()) return; + const text = content(); + const newContent = text.replace( + new RegExp(RegExp.escape(searchTerm()), "g"), + replacement, + ); + updatePageContent(newContent); + setCurrentMatchIndex(0); + }; + + const handleSearchTermChange = (term: string) => { + setSearchTerm(term); + setCurrentMatchIndex(0); + }; + + const handleOpenSearch = () => { + setIsSearchOpen(true); + setSearchTerm(""); + setCurrentMatchIndex(0); + }; + + const handleCloseSearch = () => { + setIsSearchOpen(false); + setSearchTerm(""); + setCurrentMatchIndex(0); + }; + return ( <> setToolbarOpacity(1)} onPagesClick={() => setIsPagesMenuOpen(!isPagesMenuOpen())} onSettingsClick={() => setIsSettingsOpen(true)} + onSearchClick={handleOpenSearch} renamePage={renamePage} /> @@ -58,12 +130,27 @@ function App() { deletePage={deletePage} /> + + { - updatePageContent(content); - if (currentPage()?.content) { + searchTerm={searchTerm()} + currentMatchIndex={matches().length > 0 ? currentMatchIndex() : -1} + onChange={(newContent) => { + updatePageContent(newContent); + if (newContent) { setToolbarOpacity(0); } else { setToolbarOpacity(1); diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index dea1b71..e7080ac 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,32 +1,147 @@ -import type { JSX } from "solid-js"; +import { createEffect, type JSX } from "solid-js"; import type { EditorSettings } from "#types"; -function Editor(props: { +interface Match { + start: number; + end: number; +} + +interface EditorProps { content: string; onChange: (content: string) => void; settings: EditorSettings; -}): JSX.Element { + searchTerm?: string; + currentMatchIndex?: number; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function setCaretPosition(element: HTMLDivElement, position: number) { + const range = document.createRange(); + const selection = window.getSelection(); + + let charCount = 0; + let found = false; + + function traverseNodes(node: Node) { + if (found) return; + + if (node.nodeType === Node.TEXT_NODE) { + const nextCount = charCount + (node.textContent?.length ?? 0); + if (position <= nextCount) { + range.setStart(node, position - charCount); + range.setEnd(node, position - charCount); + found = true; + } + charCount = nextCount; + } else { + for (const child of Array.from(node.childNodes)) { + traverseNodes(child); + if (found) return; + } + } + } + + traverseNodes(element); + + if (!found) { + range.selectNodeContents(element); + range.collapse(false); + } + + selection?.removeAllRanges(); + selection?.addRange(range); +} + +function Editor(props: EditorProps): JSX.Element { + let editorRef: HTMLDivElement | undefined; + + const renderContent = (): string => { + const text = props.content; + if (!props.searchTerm || !text) return escapeHtml(text); + + const escaped = escapeHtml(text); + const searchEscaped = props.searchTerm.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ); + const regex = new RegExp(`(${searchEscaped})`, "gi"); + + return escaped.replace( + regex, + '$1', + ); + }; + + const handleInput = () => { + if (!editorRef) return; + const text = editorRef.innerText; + props.onChange(text); + }; + + createEffect(() => { + if (editorRef && props.searchTerm) { + const matches = getMatches(props.content, props.searchTerm); + if (matches.length > 0 && props.currentMatchIndex !== undefined) { + const match = matches[props.currentMatchIndex]; + if (match) { + setCaretPosition(editorRef, match.start); + } + } + } + }); + return (
-