diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d00cfee..8916c58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: test: - name: Lint and Build + name: Test and Build runs-on: ubuntu-latest steps: @@ -31,5 +31,8 @@ jobs: - name: Lint run: bun run lint + - name: Run tests + run: bun run test + - name: Build run: bun run build diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b683683 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Guide + +## Avaliable commands + +```bash +bun lint +bun format +bun run test +``` diff --git a/biome.json b/biome.jsonc similarity index 76% rename from biome.json rename to biome.jsonc index ce0692c..0020c28 100644 --- a/biome.json +++ b/biome.jsonc @@ -7,7 +7,9 @@ "recommended": true, "noStaticElementInteractions": "off", "useKeyWithClickEvents": "off" - } + }, + "style": { "noNonNullAssertion": "error" }, + "complexity": { "noVoid": "error" } } }, "css": { "parser": { "tailwindDirectives": true } } 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..9d52a1c 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,8 +1,11 @@ -import { createSignal } from "solid-js"; +import { createMemo, createSignal, onCleanup, onMount } 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,11 @@ 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 [caseSensitive, setCaseSensitive] = createSignal(false); const [toolbarOpacity, setToolbarOpacity] = createSignal(1); @@ -29,6 +37,88 @@ function App() { const PAGES_BROKEN = ""; + const content = () => currentPage()?.content ?? ""; + + const matches = createMemo(() => + getMatches(content(), searchTerm(), caseSensitive()), + ); + + 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]?.start; + 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(), caseSensitive()); + if (newMatches.length > 0) { + setCurrentMatchIndex(Math.min(idx, newMatches.length - 1)); + } + }; + + const handleReplaceAll = (replacement: string) => { + if (!searchTerm()) return; + const text = content(); + const flags = caseSensitive() ? "g" : "gi"; + const newContent = text.replace( + new RegExp(RegExp.escape(searchTerm()), flags), + 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); + }; + + onMount(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.key === "F3" || + (e.ctrlKey && e.key === "f") || + (e.ctrlKey && e.key === "h") + ) { + e.preventDefault(); + handleOpenSearch(); + } + }; + window.addEventListener("keydown", handleKeyDown); + onCleanup(() => window.removeEventListener("keydown", handleKeyDown)); + }); + return ( <> setToolbarOpacity(1)} onPagesClick={() => setIsPagesMenuOpen(!isPagesMenuOpen())} onSettingsClick={() => setIsSettingsOpen(true)} + onSearchClick={handleOpenSearch} renamePage={renamePage} /> @@ -58,12 +149,31 @@ function App() { deletePage={deletePage} /> + + { - updatePageContent(content); - if (currentPage()?.content) { + searchTerm={searchTerm()} + caseSensitive={caseSensitive()} + currentMatchIndex={matches().length > 0 ? currentMatchIndex() : -1} + isSearchOpen={isSearchOpen()} + 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..2d0f3de 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,30 +1,157 @@ -import type { JSX } from "solid-js"; - +import { createEffect, type JSX } from "solid-js"; +import { escapeHtml } from "#lib/escape-html"; +import { getMatches } from "#lib/get-matches"; import type { EditorSettings } from "#types"; -function Editor(props: { +interface EditorProps { content: string; onChange: (content: string) => void; settings: EditorSettings; -}): JSX.Element { + searchTerm?: string; + caseSensitive?: boolean; + currentMatchIndex?: number; + isSearchOpen?: boolean; +} + +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 = RegExp.escape(escapeHtml(props.searchTerm)); + const flags = props.caseSensitive ? "" : "i"; + const regex = new RegExp(`(${searchEscaped})`, `g${flags}`); + + return escaped.replace( + regex, + '$1', + ); + }; + + const handleInput = () => { + if (!editorRef) return; + const text = editorRef.innerText; + props.onChange(text); + }; + + createEffect(() => { + if (!editorRef) return; + const selection = window.getSelection(); + let savedOffset = 0; + let hadSelection = false; + + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(editorRef); + preCaretRange.setEnd(range.endContainer, range.endOffset); + savedOffset = preCaretRange.toString().length; + hadSelection = true; + } + + const html = renderContent(); + if (html !== editorRef.innerHTML) { + editorRef.innerHTML = html; + } + + if (document.activeElement === editorRef && hadSelection) { + setCaretPosition(editorRef, savedOffset); + } + }); + + createEffect(() => { + if (editorRef && props.searchTerm) { + const matches = getMatches( + props.content, + props.searchTerm, + props.caseSensitive, + ); + if (matches.length > 0 && props.currentMatchIndex !== undefined) { + const match = matches[props.currentMatchIndex]; + if (match && document.activeElement === editorRef) { + setCaretPosition(editorRef, match.start); + } + } + } + }); + return (
-