Skip to content
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
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"identifier": "com.scratch.app",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
Expand Down
82 changes: 76 additions & 6 deletions src/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,8 @@ export function Editor({
// Search state
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [replaceQuery, setReplaceQuery] = useState("");
const [isReplaceOpen, setIsReplaceOpen] = useState(false);
const [searchMatches, setSearchMatches] = useState<
Array<{ from: number; to: number }>
>([]);
Expand Down Expand Up @@ -1305,6 +1307,55 @@ export function Editor({
setSearchQuery(query);
}, []);

const replaceCurrent = useCallback((replaceText: string) => {
if (!editor || !searchQuery.trim()) return;

// Recompute from current doc state to avoid stale debounced matches.
const currentMatches = findMatches(searchQuery, editor);
if (currentMatches.length === 0) return;

const safeIndex = Math.min(currentMatchIndex, currentMatches.length - 1);
const match = currentMatches[safeIndex];
if (!match) return;

editor.view.dispatch(
editor.state.tr.insertText(replaceText, match.from, match.to)
);

const newMatches = findMatches(searchQuery, editor);
setSearchMatches(newMatches);

if (newMatches.length > 0) {
// Move to the first match after the replaced range.
const nextPos = match.from + replaceText.length;
const nextIndex = newMatches.findIndex((m) => m.from >= nextPos);
const resolvedIndex = nextIndex === -1 ? 0 : nextIndex;
setCurrentMatchIndex(resolvedIndex);
updateSearchDecorations(newMatches, resolvedIndex, editor);
} else {
setCurrentMatchIndex(0);
updateSearchDecorations([], 0, editor);
}
}, [editor, searchQuery, currentMatchIndex, findMatches, updateSearchDecorations]);

const replaceAll = useCallback((replaceText: string) => {
if (!editor || !searchQuery) return;
const currentMatches = findMatches(searchQuery, editor);
if (currentMatches.length === 0) return;

const tr = editor.state.tr;
for (let i = currentMatches.length - 1; i >= 0; i--) {
const match = currentMatches[i];
tr.insertText(replaceText, match.from, match.to);
}
editor.view.dispatch(tr);

const newMatches = findMatches(searchQuery, editor);
setSearchMatches(newMatches);
setCurrentMatchIndex(0);
updateSearchDecorations(newMatches, 0, editor);
}, [editor, searchQuery, findMatches, updateSearchDecorations]);

// Debounced search effect
useEffect(() => {
if (!searchQuery.trim()) {
Expand Down Expand Up @@ -1764,23 +1815,26 @@ export function Editor({
});
}, []);

// Cmd+F to open search (works when document/editor area is focused)
// Cmd+F to open search, Cmd+H to open replace (works when document/editor area is focused)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isF = e.key.toLowerCase() === "f";
const isH = e.key.toLowerCase() === "h";
if (
(e.metaKey || e.ctrlKey) &&
!e.shiftKey &&
e.key.toLowerCase() === "f"
(isF || isH)
) {
if (!currentNote || !editor) return;

const target = e.target as HTMLElement;
const tagName = target.tagName.toLowerCase();

// Don't intercept if user is in an input/textarea (except the editor itself)
// Don't intercept if user is in an input/textarea (except the editor itself or search toolbar)
if (
(tagName === "input" || tagName === "textarea") &&
!target.closest(".ProseMirror")
!target.closest(".ProseMirror") &&
!target.closest(".search-toolbar-container")
) {
return;
}
Expand All @@ -1792,18 +1846,26 @@ export function Editor({

// Open search for the editor
e.preventDefault();
openEditorSearch();
setSearchOpen(true);
if (isH) {
setIsReplaceOpen(true);
}
requestAnimationFrame(() => {
searchInputRef.current?.focus();
});
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [editor, currentNote, openEditorSearch]);
}, [editor, currentNote]);

// Clear search on note switch
useEffect(() => {
if (currentNote?.id) {
setSearchOpen(false);
setSearchQuery("");
setReplaceQuery("");
setIsReplaceOpen(false);
setSearchMatches([]);
setCurrentMatchIndex(0);
// Clear decorations
Expand Down Expand Up @@ -2415,6 +2477,8 @@ export function Editor({
onClose={() => {
setSearchOpen(false);
setSearchQuery("");
setReplaceQuery("");
setIsReplaceOpen(false);
setSearchMatches([]);
setCurrentMatchIndex(0);
// Clear decorations and refocus editor
Expand All @@ -2427,6 +2491,12 @@ export function Editor({
searchMatches.length === 0 ? 0 : currentMatchIndex + 1
}
totalMatches={searchMatches.length}
replaceQuery={replaceQuery}
onReplaceChange={setReplaceQuery}
onReplace={() => replaceCurrent(replaceQuery)}
onReplaceAll={() => replaceAll(replaceQuery)}
isReplaceOpen={isReplaceOpen}
onToggleReplace={() => setIsReplaceOpen(!isReplaceOpen)}
/>
</div>
</div>
Expand Down
148 changes: 117 additions & 31 deletions src/components/editor/SearchToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { useEffect, type RefObject } from "react";
import { Input, IconButton } from "../ui";
import { ArrowUpIcon, ArrowDownIcon, XIcon } from "../icons";
import {
ArrowUpIcon,
ArrowDownIcon,
XIcon,
ChevronDownIcon,
ChevronRightIcon,
ReplaceIcon,
ReplaceAllIcon,
} from "../icons";
import { shift } from "../../lib/platform";

interface SearchToolbarProps {
Expand All @@ -12,6 +20,13 @@ interface SearchToolbarProps {
currentMatch: number;
totalMatches: number;
inputRef: RefObject<HTMLInputElement | null>;
// Replace functionality props
replaceQuery: string;
onReplaceChange: (value: string) => void;
onReplace: () => void;
onReplaceAll: () => void;
isReplaceOpen: boolean;
onToggleReplace: () => void;
}

export function SearchToolbar({
Expand All @@ -23,6 +38,12 @@ export function SearchToolbar({
currentMatch,
totalMatches,
inputRef,
replaceQuery,
onReplaceChange,
onReplace,
onReplaceAll,
isReplaceOpen,
onToggleReplace,
}: SearchToolbarProps) {
// Auto-focus input on mount
useEffect(() => {
Expand All @@ -49,43 +70,108 @@ export function SearchToolbar({
}
};

return (
<div className="flex items-center gap-1.5 bg-bg border border-border rounded-lg shadow-lg p-1">
<Input
ref={inputRef}
type="text"
value={query}
onChange={(e) => onChange(e.target.value)}
placeholder="Find in note..."
className="w-55 h-8 text-sm"
onKeyDown={handleKeyDown}
/>

<span className="text-xs text-text-muted whitespace-nowrap px-1 min-w-17">
{totalMatches > 0 ? `${currentMatch}/${totalMatches}` : "Not found"}
</span>
const handleReplaceKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
if (e.metaKey || e.ctrlKey) {
onReplaceAll();
} else {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
onReplace();
}
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onClose();
} else if (e.key === "Tab") {
e.stopPropagation();
}
};

<div className="flex items-center gap-px ml-1">
return (
<div className="flex flex-col gap-1.5 bg-bg border border-border rounded-lg shadow-lg p-1.5 w-85 text-text search-toolbar-container">
{/* First Row: Find */}
<div className="flex items-center gap-1.5">
<IconButton
onClick={onPrevious}
disabled={totalMatches === 0}
title={`Previous match (${shift}↵)`}
onClick={onToggleReplace}
title={isReplaceOpen ? "Hide Replace" : "Show Replace"}
className="shrink-0"
>
<ArrowUpIcon className="w-4.5 h-4.5 stroke-[1.5]" />
{isReplaceOpen ? (
<ChevronDownIcon className="w-4 h-4 stroke-[1.8]" />
) : (
<ChevronRightIcon className="w-4 h-4 stroke-[1.8]" />
)}
</IconButton>

<IconButton
onClick={onNext}
disabled={totalMatches === 0}
title="Next match (↵)"
>
<ArrowDownIcon className="w-4.5 h-4.5 stroke-[1.5]" />
</IconButton>
<Input
ref={inputRef}
type="text"
value={query}
onChange={(e) => onChange(e.target.value)}
placeholder="Find in note..."
className="flex-1 h-8 text-sm"
onKeyDown={handleKeyDown}
/>

<IconButton onClick={onClose} title="Close (Esc)">
<XIcon className="w-4.5 h-4.5 stroke-[1.5]" />
</IconButton>
<span className="text-xs text-text-muted whitespace-nowrap px-1 min-w-10 text-right">
{totalMatches > 0 ? `${currentMatch}/${totalMatches}` : "0/0"}
</span>

<div className="flex items-center gap-px shrink-0">
<IconButton
onClick={onPrevious}
disabled={totalMatches === 0}
title={`Previous match (${shift}↵)`}
>
<ArrowUpIcon className="w-4.5 h-4.5 stroke-[1.5]" />
</IconButton>

<IconButton
onClick={onNext}
disabled={totalMatches === 0}
title="Next match (↵)"
>
<ArrowDownIcon className="w-4.5 h-4.5 stroke-[1.5]" />
</IconButton>

<IconButton onClick={onClose} title="Close (Esc)">
<XIcon className="w-4.5 h-4.5 stroke-[1.5]" />
</IconButton>
</div>
</div>

{/* Second Row: Replace */}
{isReplaceOpen && (
<div className="flex items-center gap-1.5 pl-8.5 animate-in slide-in-from-top-1 duration-150">
<Input
type="text"
value={replaceQuery}
onChange={(e) => onReplaceChange(e.target.value)}
placeholder="Replace with..."
className="flex-1 h-8 text-sm"
onKeyDown={handleReplaceKeyDown}
/>

<div className="flex items-center gap-px shrink-0">
<IconButton
onClick={onReplace}
disabled={totalMatches === 0}
title="Replace (↵)"
>
<ReplaceIcon className="w-4.5 h-4.5 stroke-[1.5]" />
</IconButton>

<IconButton
onClick={onReplaceAll}
disabled={totalMatches === 0}
title="Replace All (Ctrl/Cmd+Enter)"
>
<ReplaceAllIcon className="w-4.5 h-4.5 stroke-[1.5]" />
</IconButton>
</div>
</div>
)}
</div>
);
}
41 changes: 41 additions & 0 deletions src/components/icons/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1518,3 +1518,44 @@ export function OllamaIcon({
</svg>
);
}

export function ReplaceIcon({ className = "w-4.5 h-4.5" }: IconProps) {
return (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9" />
<path d="M21 3l-7 7" />
<path d="M21 3h-5" />
<path d="M21 3v5" />
</svg>
);
}

export function ReplaceAllIcon({ className = "w-4.5 h-4.5" }: IconProps) {
return (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9" />
<path d="M21 3l-7 7" />
<path d="M21 3h-5" />
<path d="M21 3v5" />
<path d="M7 14h6" />
<path d="M7 10h4" />
</svg>
);
}

2 changes: 1 addition & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default defineConfig(async () => ({
server: {
port: 1420,
strictPort: true,
host: host || false,
host: host || "127.0.0.1",
hmr: host
? {
protocol: "ws",
Expand Down