feat: search and replace
This commit is contained in:
1 parent
98e7ee2458
commit
3b299809e1
7 files changed
+490
-2
No files matched your search
+44
-1
@@ -5,6 +5,7 @@
|
||||
import EditorComponent from './lib/components/Editor.svelte';
|
||||
import Toolbar from './lib/components/Toolbar.svelte';
|
||||
import ToolbarExpanded from './lib/components/ToolbarExpanded.svelte';
|
||||
import FindReplaceBar from './lib/components/FindReplaceBar.svelte';
|
||||
import { buildOdt, deriveFilename } from './lib/export/odt';
|
||||
import { exportPdf, printPdf } from './lib/export/pdf';
|
||||
import { supportsFsAccess, saveOdt, saveAsOdt, openOdt } from './lib/export/saveFile';
|
||||
@@ -40,6 +41,24 @@
|
||||
let activeEditor = $derived(hfActive ? hfEditor : editor);
|
||||
let activeTick = $derived(hfActive ? hfTick : tick);
|
||||
|
||||
// Find & Replace bar (searchReplace.ts). Targets the body editor; positioned just
|
||||
// below the toolbar region (toolbarRegionH tracks its height, expanded or not).
|
||||
let findOpen = $state(false);
|
||||
let findMode: 'find' | 'replace' = $state('find');
|
||||
let findNonce = $state(0);
|
||||
let toolbarRegionH = $state(0);
|
||||
|
||||
function openFind(mode: 'find' | 'replace') {
|
||||
findMode = mode;
|
||||
findOpen = true;
|
||||
findNonce++;
|
||||
}
|
||||
|
||||
function closeFind() {
|
||||
findOpen = false;
|
||||
editor?.commands.clearSearch();
|
||||
}
|
||||
|
||||
// Word/character counts for the status-bar counter. Reading `tick` makes these
|
||||
// recompute on every body transaction (incl. selection changes), so they stay
|
||||
// live without subscribing to ProseMirror directly.
|
||||
@@ -441,6 +460,17 @@
|
||||
e.preventDefault();
|
||||
handlePrintPdf();
|
||||
}
|
||||
// Ctrl/Cmd+F → Find, Ctrl/Cmd+H → Find & Replace (suppress the browser's own find).
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 'f' || e.key === 'F')) {
|
||||
e.preventDefault();
|
||||
openFind('find');
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 'h' || e.key === 'H')) {
|
||||
e.preventDefault();
|
||||
openFind('replace');
|
||||
}
|
||||
// Escape closes the bar (when it isn't handled inside an input).
|
||||
if (e.key === 'Escape' && findOpen) closeFind();
|
||||
}
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => window.removeEventListener('keydown', onKeydown);
|
||||
@@ -471,7 +501,7 @@
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<div class="toolbar-region">
|
||||
<div class="toolbar-region" bind:clientHeight={toolbarRegionH}>
|
||||
<div class="toolbar-clip" id="primary-toolbar" bind:this={toolbarClipEl} onwheel={onToolbarWheel}>
|
||||
<div class="toolbar-stack" bind:this={toolbarStackEl} style="transform: translateX(-{tbScroll}px);">
|
||||
<header class:expanded={toolbarExpanded}>
|
||||
@@ -706,6 +736,11 @@
|
||||
{pageMargins}
|
||||
orientation={pageOrientation}
|
||||
/>
|
||||
{#if findOpen && editor}
|
||||
<div class="find-bar-anchor" style="top: {toolbarRegionH + 8}px;">
|
||||
<FindReplaceBar {editor} {tick} mode={findMode} focusNonce={findNonce} onClose={closeFind} />
|
||||
</div>
|
||||
{/if}
|
||||
<footer class="statusbar">
|
||||
<div class="sb-left">
|
||||
<span>Page {currentPage} of {numPages}</span>
|
||||
@@ -775,6 +810,14 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Find & Replace bar: floats at the top-right of the editing area, just under the
|
||||
toolbar (top is set inline from the toolbar height). Below the toolbar's z-index. */
|
||||
.find-bar-anchor {
|
||||
position: fixed;
|
||||
right: 1.5rem;
|
||||
z-index: 190;
|
||||
}
|
||||
|
||||
/* Toolbar scroll region. A stacking context (z-index) so the whole toolbar — incl.
|
||||
its dropdowns, which live inside the translated stack — paints above the editor
|
||||
and its overlays. */
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { onMount } from 'svelte';
|
||||
import { getSearchState } from '../editor/extensions/searchReplace';
|
||||
|
||||
let { editor, tick, mode, focusNonce, onClose }:
|
||||
{ editor: Editor | null; tick: number; mode: 'find' | 'replace'; focusNonce: number; onClose: () => void } = $props();
|
||||
|
||||
let findText = $state('');
|
||||
let replaceText = $state('');
|
||||
let matchCase = $state(false);
|
||||
let wholeWord = $state(false);
|
||||
let showReplace = $state(false); // set from `mode` in the focusNonce effect below
|
||||
let findInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// Live match count / current index, recomputed on every editor transaction.
|
||||
let results = $derived.by(() => {
|
||||
void tick;
|
||||
return editor ? getSearchState(editor.state) : { count: 0, current: -1, term: '' };
|
||||
});
|
||||
|
||||
function applySearch() {
|
||||
editor?.commands.setSearch({ term: findText, matchCase, wholeWord });
|
||||
editor?.commands.scrollToCurrent();
|
||||
}
|
||||
|
||||
function next() { editor?.commands.findNext(); }
|
||||
function prev() { editor?.commands.findPrevious(); }
|
||||
function replaceOne() { editor?.commands.replaceCurrent(replaceText); }
|
||||
function replaceEvery() { editor?.commands.replaceAll(replaceText); }
|
||||
|
||||
function onFindKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? prev() : next(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
|
||||
}
|
||||
|
||||
function onReplaceKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); replaceOne(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
|
||||
}
|
||||
|
||||
function toggleCase() { matchCase = !matchCase; applySearch(); }
|
||||
function toggleWord() { wholeWord = !wholeWord; applySearch(); }
|
||||
|
||||
// Prefill from the current selection (single line) on first open.
|
||||
onMount(() => {
|
||||
if (editor) {
|
||||
const { from, to } = editor.state.selection;
|
||||
if (to > from) {
|
||||
const sel = editor.state.doc.textBetween(from, to, '\n');
|
||||
if (sel && !sel.includes('\n')) findText = sel;
|
||||
}
|
||||
}
|
||||
applySearch();
|
||||
});
|
||||
|
||||
// Each Ctrl+F / Ctrl+H bumps focusNonce → refocus and reveal the replace row as asked.
|
||||
$effect(() => {
|
||||
void focusNonce;
|
||||
showReplace = showReplace || mode === 'replace';
|
||||
queueMicrotask(() => { findInput?.focus(); findInput?.select(); });
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="find-bar" role="dialog" aria-label="Find and replace">
|
||||
<button class="fb-expand" onclick={() => (showReplace = !showReplace)} title={showReplace ? 'Hide replace' : 'Show replace'} aria-label="Toggle replace">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true" style="transform: rotate({showReplace ? 90 : 0}deg)">
|
||||
<path d="M3 1.5l3.5 3.5L3 8.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="fb-rows">
|
||||
<div class="fb-row">
|
||||
<input
|
||||
bind:this={findInput}
|
||||
bind:value={findText}
|
||||
oninput={applySearch}
|
||||
onkeydown={onFindKeydown}
|
||||
type="text"
|
||||
placeholder="Find"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span class="fb-count">
|
||||
{#if findText && results.count === 0}No results
|
||||
{:else if results.count > 0}{results.current + 1}/{results.count}
|
||||
{/if}
|
||||
</span>
|
||||
<button class="fb-opt" class:on={matchCase} onclick={toggleCase} title="Match case" aria-pressed={matchCase}>Aa</button>
|
||||
<button class="fb-opt fb-word" class:on={wholeWord} onclick={toggleWord} title="Whole word" aria-pressed={wholeWord}>
|
||||
<span>W</span>
|
||||
</button>
|
||||
<button class="fb-nav" onclick={prev} disabled={!results.count} title="Previous (Shift+Enter)" aria-label="Previous match">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2.5 7.5L6 4l3.5 3.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button class="fb-nav" onclick={next} disabled={!results.count} title="Next (Enter)" aria-label="Next match">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2.5 4.5L6 8l3.5-3.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button class="fb-close" onclick={onClose} title="Close (Esc)" aria-label="Close">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2.5 2.5l7 7M9.5 2.5l-7 7" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showReplace}
|
||||
<div class="fb-row">
|
||||
<input
|
||||
bind:value={replaceText}
|
||||
onkeydown={onReplaceKeydown}
|
||||
type="text"
|
||||
placeholder="Replace"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button class="fb-text-btn" onclick={replaceOne} disabled={!results.count} title="Replace current match">Replace</button>
|
||||
<button class="fb-text-btn" onclick={replaceEvery} disabled={!results.count} title="Replace all matches">All</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.find-bar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--color-toolbar-bg, #fff);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.fb-rows { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.fb-row { display: flex; align-items: center; gap: 0.25rem; }
|
||||
|
||||
input {
|
||||
width: 14rem;
|
||||
box-sizing: border-box;
|
||||
padding: 0.3rem 0.45rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
/* --color-surface (not page-bg): page-bg stays white in dark mode, surface follows the theme. */
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.fb-count {
|
||||
min-width: 4.2rem;
|
||||
text-align: center;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
padding: 0 0.4rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) { background: var(--color-btn-hover, rgba(0, 0, 0, 0.06)); }
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
.fb-opt.on { background: var(--color-primary); color: #fff; }
|
||||
.fb-opt { font-weight: 600; }
|
||||
.fb-word span { text-decoration: underline; }
|
||||
.fb-text-btn { min-width: auto; padding: 0 0.6rem; border-color: var(--color-border); }
|
||||
.fb-expand { min-width: 1.2rem; width: 1.2rem; align-self: center; }
|
||||
</style>
|
||||
@@ -84,7 +84,8 @@
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-page-bg, #fff);
|
||||
/* --color-surface (not page-bg): page-bg stays white in dark mode, surface follows the theme. */
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { PageBreak } from './extensions/pageBreak';
|
||||
import { Indent } from './extensions/indent';
|
||||
import { FormattingMarks } from './extensions/formattingMarks';
|
||||
import { SpellCheck } from './extensions/spellCheck';
|
||||
import { SearchReplace } from './extensions/searchReplace';
|
||||
import { TableView } from './extensions/tableView';
|
||||
import { TableColumnResize } from './extensions/tableColumnResize';
|
||||
import { TableRowResize } from './extensions/tableRowResize';
|
||||
@@ -91,4 +92,5 @@ export const extensions = [
|
||||
PageBreaks,
|
||||
FormattingMarks,
|
||||
SpellCheck,
|
||||
SearchReplace,
|
||||
];
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import type { EditorState, Transaction } from '@tiptap/pm/state';
|
||||
import type { Node as PmNode } from '@tiptap/pm/model';
|
||||
|
||||
// Find & Replace: highlights all matches via decorations, navigates/replaces via commands.
|
||||
// Pure editor feature — never touches the document model beyond the actual replacements.
|
||||
|
||||
export interface SearchOptions { term: string; matchCase: boolean; wholeWord: boolean; }
|
||||
interface Match { from: number; to: number; }
|
||||
interface SearchState extends SearchOptions {
|
||||
matches: Match[];
|
||||
current: number; // index into matches, or -1
|
||||
deco: DecorationSet;
|
||||
}
|
||||
|
||||
export const searchKey = new PluginKey<SearchState>('searchReplace');
|
||||
const SET_SEARCH = 'searchReplace/setSearch';
|
||||
const SET_CURRENT = 'searchReplace/setCurrent';
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function buildSearchRegex(term: string, matchCase: boolean, wholeWord: boolean): RegExp | null {
|
||||
if (!term) return null;
|
||||
let pat = escapeRegExp(term);
|
||||
if (wholeWord) pat = `\\b${pat}\\b`;
|
||||
try { return new RegExp(pat, matchCase ? 'g' : 'gi'); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// All [start, end) char ranges of a global regex in `text` (skips zero-width matches).
|
||||
export function regexRanges(text: string, re: RegExp): [number, number][] {
|
||||
const out: [number, number][] = [];
|
||||
re.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m[0].length === 0) { re.lastIndex++; continue; }
|
||||
out.push([m.index, m.index + m[0].length]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Matches within each textblock, mapped to doc positions. Matches span runs/marks but
|
||||
// never block boundaries (each textblock is searched on its own concatenated text).
|
||||
function findMatches(doc: PmNode, re: RegExp | null): Match[] {
|
||||
if (!re) return [];
|
||||
const matches: Match[] = [];
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isTextblock) return true;
|
||||
let text = '';
|
||||
const map: number[] = []; // map[i] = doc position of char i
|
||||
node.forEach((child, offset) => {
|
||||
if (child.isText) {
|
||||
const t = child.text ?? '';
|
||||
for (let k = 0; k < t.length; k++) { text += t[k]; map.push(pos + 1 + offset + k); }
|
||||
} else {
|
||||
text += ''; // inline atom (image/hardBreak): one non-matching slot
|
||||
map.push(pos + 1 + offset);
|
||||
}
|
||||
});
|
||||
for (const [s, e] of regexRanges(text, re)) matches.push({ from: map[s], to: map[e - 1] + 1 });
|
||||
return false;
|
||||
});
|
||||
return matches;
|
||||
}
|
||||
|
||||
function buildDeco(doc: PmNode, matches: Match[], current: number): DecorationSet {
|
||||
if (!matches.length) return DecorationSet.empty;
|
||||
return DecorationSet.create(doc, matches.map((m, i) =>
|
||||
Decoration.inline(m.from, m.to, { class: i === current ? 'search-match search-match-current' : 'search-match' }),
|
||||
));
|
||||
}
|
||||
|
||||
// First match at/after the cursor (wraps to the first match), or -1 when none.
|
||||
function pickCurrent(matches: Match[], selFrom: number): number {
|
||||
if (!matches.length) return -1;
|
||||
const i = matches.findIndex(m => m.from >= selFrom);
|
||||
return i === -1 ? 0 : i;
|
||||
}
|
||||
|
||||
const EMPTY: SearchState = { term: '', matchCase: false, wholeWord: false, matches: [], current: -1, deco: DecorationSet.empty };
|
||||
|
||||
// Match count + current index for the toolbar UI (FindReplaceBar.svelte).
|
||||
export function getSearchState(state: EditorState): { count: number; current: number; term: string } {
|
||||
const s = searchKey.getState(state);
|
||||
return { count: s?.matches.length ?? 0, current: s?.current ?? -1, term: s?.term ?? '' };
|
||||
}
|
||||
|
||||
function moveCurrent(state: EditorState, dispatch: ((tr: Transaction) => void) | undefined, dir: 1 | -1): boolean {
|
||||
const s = searchKey.getState(state);
|
||||
if (!s || !s.matches.length) return false;
|
||||
const n = s.matches.length;
|
||||
const idx = s.current < 0 ? (dir > 0 ? 0 : n - 1) : (s.current + dir + n) % n;
|
||||
const m = s.matches[idx];
|
||||
if (dispatch) {
|
||||
const tr = state.tr.setMeta(SET_CURRENT, idx);
|
||||
tr.setSelection(TextSelection.create(tr.doc, m.from, m.to));
|
||||
tr.scrollIntoView();
|
||||
dispatch(tr);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
searchReplace: {
|
||||
setSearch: (opts: SearchOptions) => ReturnType;
|
||||
findNext: () => ReturnType;
|
||||
findPrevious: () => ReturnType;
|
||||
scrollToCurrent: () => ReturnType;
|
||||
replaceCurrent: (text: string) => ReturnType;
|
||||
replaceAll: (text: string) => ReturnType;
|
||||
clearSearch: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const SearchReplace = Extension.create({
|
||||
name: 'searchReplace',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin<SearchState>({
|
||||
key: searchKey,
|
||||
state: {
|
||||
init: () => EMPTY,
|
||||
apply(tr: Transaction, old: SearchState, _oldState: EditorState, newState: EditorState): SearchState {
|
||||
const set = tr.getMeta(SET_SEARCH) as SearchOptions | undefined;
|
||||
if (set) {
|
||||
const matches = findMatches(newState.doc, buildSearchRegex(set.term, set.matchCase, set.wholeWord));
|
||||
const current = pickCurrent(matches, newState.selection.from);
|
||||
return { ...set, matches, current, deco: buildDeco(newState.doc, matches, current) };
|
||||
}
|
||||
const cur = tr.getMeta(SET_CURRENT) as number | undefined;
|
||||
if (cur !== undefined) return { ...old, current: cur, deco: buildDeco(newState.doc, old.matches, cur) };
|
||||
if (tr.docChanged && old.term) {
|
||||
const matches = findMatches(newState.doc, buildSearchRegex(old.term, old.matchCase, old.wholeWord));
|
||||
const current = pickCurrent(matches, newState.selection.from);
|
||||
return { ...old, matches, current, deco: buildDeco(newState.doc, matches, current) };
|
||||
}
|
||||
return old;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) { return searchKey.getState(state)?.deco; },
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setSearch: (opts: SearchOptions) => ({ state, dispatch }) => {
|
||||
if (dispatch) dispatch(state.tr.setMeta(SET_SEARCH, opts));
|
||||
return true;
|
||||
},
|
||||
findNext: () => ({ state, dispatch }) => moveCurrent(state, dispatch, 1),
|
||||
findPrevious: () => ({ state, dispatch }) => moveCurrent(state, dispatch, -1),
|
||||
scrollToCurrent: () => ({ state, dispatch }) => {
|
||||
const s = searchKey.getState(state);
|
||||
if (!s || s.current < 0) return false;
|
||||
const m = s.matches[s.current];
|
||||
if (dispatch) dispatch(state.tr.setSelection(TextSelection.create(state.doc, m.from, m.to)).scrollIntoView());
|
||||
return true;
|
||||
},
|
||||
replaceCurrent: (text: string) => ({ state, dispatch }) => {
|
||||
const s = searchKey.getState(state);
|
||||
if (!s || s.current < 0 || !s.matches.length) return false;
|
||||
const m = s.matches[s.current];
|
||||
if (dispatch) {
|
||||
// Marks of the first matched char (resolving at m.from would read the
|
||||
// boundary before the match, i.e. the preceding run's formatting).
|
||||
const marks = state.doc.resolve(m.from + 1).marks();
|
||||
const tr = state.tr;
|
||||
if (text) tr.replaceWith(m.from, m.to, state.schema.text(text, marks));
|
||||
else tr.delete(m.from, m.to);
|
||||
// Land the cursor after the replacement so the recompute picks the next match.
|
||||
tr.setSelection(TextSelection.create(tr.doc, m.from + text.length));
|
||||
tr.scrollIntoView();
|
||||
dispatch(tr);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
replaceAll: (text: string) => ({ state, dispatch }) => {
|
||||
const s = searchKey.getState(state);
|
||||
if (!s || !s.matches.length) return false;
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
// Reverse order so earlier positions stay valid as we splice.
|
||||
for (let i = s.matches.length - 1; i >= 0; i--) {
|
||||
const m = s.matches[i];
|
||||
const marks = state.doc.resolve(m.from + 1).marks();
|
||||
if (text) tr.replaceWith(m.from, m.to, state.schema.text(text, marks));
|
||||
else tr.delete(m.from, m.to);
|
||||
}
|
||||
dispatch(tr);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
clearSearch: () => ({ state, dispatch }) => {
|
||||
const s = searchKey.getState(state);
|
||||
if (dispatch) dispatch(state.tr.setMeta(SET_SEARCH, { term: '', matchCase: s?.matchCase ?? false, wholeWord: s?.wholeWord ?? false }));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -154,6 +154,15 @@
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* Find & Replace match highlights (searchReplace.ts); current match is stronger. */
|
||||
.paper .tiptap .search-match {
|
||||
background: rgba(255, 213, 0, 0.45);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.paper .tiptap .search-match-current {
|
||||
background: rgba(255, 145, 0, 0.8);
|
||||
}
|
||||
|
||||
/* Inline, as-character images (image.ts). The wrapper reserves the rotated bounding
|
||||
box; the rotor holds the <img>, resize handles and rotate grip, rotating together. */
|
||||
.paper .tiptap .image-node {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSearchRegex, regexRanges } from '../../src/lib/editor/extensions/searchReplace';
|
||||
|
||||
function ranges(text: string, term: string, matchCase = false, wholeWord = false): [number, number][] {
|
||||
const re = buildSearchRegex(term, matchCase, wholeWord);
|
||||
return re ? regexRanges(text, re) : [];
|
||||
}
|
||||
|
||||
describe('buildSearchRegex', () => {
|
||||
it('returns null for an empty term', () => {
|
||||
expect(buildSearchRegex('', false, false)).toBeNull();
|
||||
});
|
||||
|
||||
it('escapes regex metacharacters (literal match)', () => {
|
||||
expect(ranges('a.b a_b', 'a.b')).toEqual([[0, 3]]); // the dot is literal, not "any char"
|
||||
});
|
||||
|
||||
it('is case-insensitive by default, case-sensitive when asked', () => {
|
||||
expect(ranges('Foo foo FOO', 'foo')).toEqual([[0, 3], [4, 7], [8, 11]]);
|
||||
expect(ranges('Foo foo FOO', 'foo', true)).toEqual([[4, 7]]);
|
||||
});
|
||||
|
||||
it('honours whole-word boundaries', () => {
|
||||
expect(ranges('cat category scat', 'cat')).toEqual([[0, 3], [4, 7], [14, 17]]);
|
||||
expect(ranges('cat category scat', 'cat', false, true)).toEqual([[0, 3]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regexRanges', () => {
|
||||
it('finds all non-overlapping matches', () => {
|
||||
expect(ranges('abab', 'ab')).toEqual([[0, 2], [2, 4]]);
|
||||
});
|
||||
|
||||
it('returns nothing when there is no match', () => {
|
||||
expect(ranges('hello', 'xyz')).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not loop on zero-width patterns', () => {
|
||||
// term is escaped so this can't actually be zero-width, but guard the helper directly
|
||||
const re = new RegExp('', 'g');
|
||||
expect(regexRanges('abc', re)).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user