Skip to content
Merged
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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:

jobs:
test:
name: Lint and Build
name: Test and Build
runs-on: ubuntu-latest

steps:
Expand All @@ -31,5 +31,8 @@ jobs:
- name: Lint
run: bun run lint

- name: Run tests
run: bun run test

- name: Build
run: bun run build
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Guide

## Avaliable commands

```bash
bun lint
bun format
bun run test
```
4 changes: 3 additions & 1 deletion biome.json → biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"recommended": true,
"noStaticElementInteractions": "off",
"useKeyWithClickEvents": "off"
}
},
"style": { "noNonNullAssertion": "error" },
"complexity": { "noVoid": "error" }
}
},
"css": { "parser": { "tailwindDirectives": true } }
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
120 changes: 115 additions & 5 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -22,13 +25,100 @@ 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);

const currentPage = () => pages()[currentPageIndex()];

const PAGES_BROKEN = "<something has broken - this page does not exist>";

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 (
<>
<Toolbar
Expand All @@ -38,6 +128,7 @@ function App() {
onMouseMove={() => setToolbarOpacity(1)}
onPagesClick={() => setIsPagesMenuOpen(!isPagesMenuOpen())}
onSettingsClick={() => setIsSettingsOpen(true)}
onSearchClick={handleOpenSearch}
renamePage={renamePage}
/>

Expand All @@ -58,12 +149,31 @@ function App() {
deletePage={deletePage}
/>

<FindReplaceModal
isOpen={isSearchOpen()}
content={content()}
searchTerm={searchTerm()}
matchCount={matches().length}
currentMatchIndex={currentMatchIndex()}
onClose={handleCloseSearch}
onSearchTermChange={handleSearchTermChange}
onNavigate={handleNavigate}
onReplace={handleReplace}
onReplaceAll={handleReplaceAll}
caseSensitive={caseSensitive()}
onCaseSensitiveChange={setCaseSensitive}
/>

<Editor
content={currentPage()?.content ?? PAGES_BROKEN}
content={content()}
settings={settings()}
onChange={(content) => {
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);
Expand Down
151 changes: 139 additions & 12 deletions src/components/Editor.tsx
Original file line number Diff line number Diff line change
@@ -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));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

RegExp.escape browser support ES2024

💡 Result:

RegExp.escape() is not an ES2024 feature; it’s standardized in ECMAScript 2025 (ES2025) (added to the spec as RegExp.escape). [3]

Browser support (from “Can I use”): [1]

  • Chrome: 136+
  • Edge: 136+
  • Firefox: 134+
  • Safari (macOS): 18.2+
  • iOS Safari: 18.2+
  • Samsung Internet: 29+

MDN summarizes this as becoming broadly available across “latest” browsers since May 2025. [2]

Sources:
[1] Can I use: RegExp.escape() (caniuse.com)
[2] MDN: RegExp.escape() (Baseline 2025 / since May 2025) (developer.mozilla.org)
[3] ECMA-262 (ECMAScript 2025) PDF mentioning RegExp.escape (ecma-international.org)

Citations:


Add a polyfill or compatibility check for RegExp.escape.

RegExp.escape is an ES2025 feature with limited browser support (Chrome 136+, Firefox 134+, Safari 18.2+, and others as of May 2025). Browsers below these versions will throw a runtime error. Either add a polyfill or verify your target environments support it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Editor.tsx` at line 73, The usage of the ES2025 RegExp.escape
in Editor.tsx (the line creating searchEscaped via
RegExp.escape(escapeHtml(props.searchTerm))) can throw in older browsers;
replace it with a safe fallback or polyfill: add a small compatibility check at
module scope (e.g., if (typeof RegExp.escape !== "function") { RegExp.escape =
/* fallback implementation */ }) or implement a local helper function (e.g.,
safeRegExpEscape) and use that instead of RegExp.escape when building
searchEscaped; update the code that references RegExp.escape (the line using
escapeHtml(props.searchTerm)) to call the fallback/helper to ensure runtime
compatibility.

const flags = props.caseSensitive ? "" : "i";
const regex = new RegExp(`(${searchEscaped})`, `g${flags}`);

return escaped.replace(
regex,
'<mark class="bg-yellow-300 dark:bg-yellow-600 rounded px-0.5">$1</mark>',
);
};
Comment on lines +56 to +69
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unify the matching rules used for highlights and indexes.

renderContent() uses global regex replacement, so it only marks non-overlapping hits, but getMatches() advances by one character and counts overlaps. Searches like ana in banana can report two matches while only one <mark> is rendered, and currentMatchIndex can point to a match the user never sees.

🔧 Minimal fix if overlaps are not intended
   while (found !== -1) {
     matches.push({ start: found, end: found + searchTerm.length });
-    pos = found + 1;
+    pos = found + searchTerm.length;
     found = searchContent.indexOf(search, pos);
   }

Also applies to: 172-186

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Editor.tsx` around lines 69 - 82, renderContent() and
getMatches() use different matching rules causing mismatched highlights vs match
counts (e.g., overlapping matches like "ana" in "banana"); make them consistent
by changing renderContent() to use the same overlapping-match logic as
getMatches() — use a lookahead-based regex (e.g., (?=(...))) or iterate with
regex.exec advancing by 1 char to find every overlapping match, then build the
escaped output by slicing and inserting the <mark> wrappers at each match offset
so the number and positions of rendered marks match getMatches() and
currentMatchIndex.


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);
}
}
}
});
Comment on lines +102 to +116
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Effect reacts to content changes, causing caret to jump during typing.

This effect tracks props.content (via getMatches). When the user types with an active search, both effects run: the first effect restores caret to typing position, then this effect immediately moves it to the current match position—disrupting typing.

Consider using a previous-value pattern to only navigate when currentMatchIndex actually changes:

🔧 Proposed fix using previous index tracking
+import { createEffect, createSignal, type JSX } from "solid-js";
...

 function Editor(props: EditorProps): JSX.Element {
   let editorRef: HTMLDivElement | undefined;
+  const [prevMatchIndex, setPrevMatchIndex] = createSignal<number | undefined>(undefined);

   // ... existing code ...

   createEffect(() => {
-    if (editorRef && props.searchTerm) {
+    const currentIndex = props.currentMatchIndex;
+    const searchTerm = props.searchTerm;
+    
+    if (editorRef && searchTerm && currentIndex !== undefined && currentIndex !== prevMatchIndex()) {
+      setPrevMatchIndex(currentIndex);
       const matches = getMatches(
         props.content,
-        props.searchTerm,
+        searchTerm,
         props.caseSensitive,
       );
-      if (matches.length > 0 && props.currentMatchIndex !== undefined) {
-        const match = matches[props.currentMatchIndex];
+      if (matches.length > 0) {
+        const match = matches[currentIndex];
         if (match) {
           setCaretPosition(editorRef, match.start);
         }
       }
     }
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Editor.tsx` around lines 114 - 128, The effect that calls
getMatches and setCaretPosition (createEffect using editorRef, props.searchTerm,
props.content, getMatches, props.currentMatchIndex) should only run when the
search index actually changes to avoid jumping caret while typing; implement a
previous-index tracker (a local mutable ref or a createSignal to hold prevIndex)
and early-return unless props.currentMatchIndex is defined and differs from
prevIndex, then compute matches (or reuse cached matches) and call
setCaretPosition(editorRef, match.start) only when the index changed, finally
update prevIndex to props.currentMatchIndex; also remove props.content from the
reactive dependencies for this navigation effect so typing doesn’t trigger it.


return (
<main>
<textarea
class="w-full resize-none [outline:none] absolute h-screen overflow-y-auto overflow-x-hidden
<div
ref={editorRef}
contentEditable={true}
spellcheck={props.settings.spellcheck}
onInput={handleInput}
class="w-full absolute h-screen overflow-y-auto overflow-x-hidden
[word-break:break-word] text-black dark:text-white caret-blue-500
bg-transparent delay-500 [scrollbar-width:thin] scroll-smooth
bg-transparent [scrollbar-width:thin] scroll-smooth
p-[calc(min(1em,20vh)+72px)_max(-372px+50vw,1em)_min(5em,15vh)]
scroll-pb-0 left-0 top-0"
scroll-pb-0 left-0 top-0 outline-none whitespace-pre-wrap"
style={{
"font-size": `${props.settings.fontSize}px`,
"font-family": props.settings.fontFamily,
"text-align": props.settings.textAlign,
}}
spellcheck={props.settings.spellcheck}
onInput={(e) => props.onChange(e.currentTarget.value)}
value={props.content}
placeholder="Start writing..."
data-placeholder="Start writing..."
/>
Comment on lines +120 to 136
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's any existing mechanism to preserve cursor position during typing
rg -n "selection|caret|cursor" src/components/Editor.tsx

Repository: rmandotdev/editor

Length of output: 259


🏁 Script executed:

wc -l src/components/Editor.tsx

Repository: rmandotdev/editor

Length of output: 90


🏁 Script executed:

cat -n src/components/Editor.tsx

Repository: rmandotdev/editor

Length of output: 5045


Setting innerHTML on contentEditable resets cursor position on every keystroke.

When the user types, handleInput triggers props.onChange, which changes props.content. This causes renderContent() to re-evaluate and innerHTML is set, resetting the cursor. The setCaretPosition effect (lines 90-100) only executes its cursor restoration logic when props.searchTerm is truthy, so during normal typing without an active search, the cursor is never restored.

Consider using a diffing approach to update only the changed text nodes, or only update innerHTML when searchTerm changes (not on every keystroke).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Editor.tsx` around lines 104 - 121, The issue is that binding
innerHTML={renderContent()} on the contentEditable div resets the caret on every
keystroke; stop setting innerHTML on every render and only mutate the DOM when
necessary (e.g., when searchTerm or content-formatting changes). Replace the
direct innerHTML prop with a controlled update inside an effect: use editorRef
to set innerHTML inside a useEffect that depends on props.searchTerm (and
initial mount/format changes) rather than props.content on every keystroke, and
ensure handleInput still calls props.onChange without overriding the live DOM;
also update setCaretPosition to run when you intentionally update DOM for search
highlights so selection restoration occurs when you change innerHTML. Use the
function names editorRef, handleInput, renderContent, setCaretPosition,
props.searchTerm, props.content, and props.onChange to locate where to apply
this change.

<style>{`
[contenteditable]:empty:before {
content: attr(data-placeholder);
color: #999;
}
mark {
background-color: #fde047;
border-radius: 2px;
padding: 0 2px;
margin: 0 -1px;
box-decoration-break: clone;
}
@media (prefers-color-scheme: dark) {
mark {
background-color: #ca8a04;
}
}
`}</style>
</main>
);
}
Expand Down
Loading