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
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
"version": "0.0.13",
"packageManager": "pnpm@9.15.4",
"scripts": {
"build": "pnpm --filter @floatboat/nexus-core build && pnpm --filter @floatboat/nexus-react build && pnpm --filter @floatboat/nexus-vue build && pnpm --filter @floatboat/nexus-preset-gfm build && pnpm --filter @floatboat/nexus-plugin-slash build && pnpm --filter @floatboat/nexus-plugin-history build && pnpm --filter @floatboat/nexus-plugin-search build && pnpm --filter @floatboat/nexus-plugin-toolbar build && pnpm --filter @floatboat/nexus-plugin-math build && pnpm --filter @floatboat/nexus-plugin-vim build && pnpm --filter @floatboat/nexus-plugin-wordcount build",
"typecheck": "pnpm -r exec tsc --noEmit",
"build": "corepack pnpm --filter \"./packages/**\" --workspace-concurrency=1 build",
"typecheck": "corepack pnpm -r exec tsc --noEmit",
"test": "vitest run",
"dev:electron-demo": "pnpm --filter @floatboat/nexus-electron-demo dev",
"build:electron-demo": "pnpm --filter @floatboat/nexus-electron-demo build",
"publish:packages": "pnpm -r --filter \"./packages/*\" publish --access public --no-git-checks"
"dev:electron-demo": "corepack pnpm --filter @floatboat/nexus-electron-demo dev",
"build:electron-demo": "corepack pnpm --filter @floatboat/nexus-electron-demo build",
"publish:packages": "corepack pnpm -r --filter \"./packages/*\" publish --access public --no-git-checks"
},
"devDependencies": {
"@testing-library/react": "^16.3.0",
Expand Down
59 changes: 59 additions & 0 deletions packages/plugin-search/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# @floatboat/nexus-plugin-search

Search panel integration and headless search helpers for
[Nexus-Editor](https://github.com/floatboatai/Nexus-Editor).

## Install

```bash
pnpm add @floatboat/nexus-plugin-search @floatboat/nexus-core
```

## Editor plugin

```ts
import { createEditor } from "@floatboat/nexus-core";
import { createSearchPlugin } from "@floatboat/nexus-plugin-search";

const editor = createEditor({
container,
plugins: [createSearchPlugin()]
});
```

The plugin registers CodeMirror's search panel with Nexus styling,
test-friendly attributes, optional query history, replace controls,
case-sensitive search, regular expressions, and whole-word matching.

## Headless helpers

```ts
import { findSearchMatches, replaceAllMatches } from "@floatboat/nexus-plugin-search";

findSearchMatches("Nexus note next", "ne", { fuzzy: true });
// [
// { from: 0, to: 2, text: "Ne" },
// { from: 6, to: 10, text: "note" },
// { from: 11, to: 13, text: "ne" }
// ]

replaceAllMatches("Nexus note next", "ne", "X", { fuzzy: true });
// "Xxus X Xxt"
```

`fuzzy: true` applies to literal searches only. It treats the query as
an ordered subsequence, returns non-overlapping ranges, honors
`caseSensitive` and `wholeWord`, and is ignored when `regexp` is true
so regular-expression behavior stays unchanged.

## API

| Export | Purpose |
|---|---|
| `createSearchPlugin(options?)` | Registers the editor search panel. |
| `findSearchMatches(doc, query, options?)` | Returns match ranges for literal, regexp, whole-word, or fuzzy search. |
| `replaceAllMatches(doc, query, replacement, options?)` | Replaces every supported match and returns the next document. |

## License

MIT
82 changes: 82 additions & 0 deletions packages/plugin-search/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export interface SearchOptions {
caseSensitive?: boolean;
wholeWord?: boolean;
regexp?: boolean;
/**
* Match literal queries as an ordered subsequence, allowing characters
* between query letters. Ignored when `regexp` is true.
*/
fuzzy?: boolean;
}

export interface SearchHistoryStorage {
Expand Down Expand Up @@ -250,6 +255,66 @@ function buildSearchPattern(query: string, options: SearchOptions = {}): RegExp
}
}

function isWordChar(value: string): boolean {
return /[\p{Letter}\p{Number}_]/u.test(value);
}

function isWholeWordMatch(doc: string, from: number, to: number): boolean {
const before = from > 0 ? doc.slice(from - 1, from) : "";
const after = to < doc.length ? doc.slice(to, to + 1) : "";
return (!before || !isWordChar(before)) && (!after || !isWordChar(after));
}

function findFuzzyMatchAt(
doc: string,
source: string,
needle: string,
start: number,
): SearchMatch | null {
let queryIndex = 0;
let to = start;

while (to < source.length && queryIndex < needle.length) {
if (source[to] === needle[queryIndex]) {
queryIndex++;
}
to++;
}

if (queryIndex !== needle.length) return null;
return { from: start, to, text: doc.slice(start, to) };
}

function findFuzzySearchMatches(
doc: string,
query: string,
options: SearchOptions
): SearchMatch[] {
const matches: SearchMatch[] = [];
const caseSensitive = options.caseSensitive === true;
const source = caseSensitive ? doc : doc.toLowerCase();
const needle = caseSensitive ? query : query.toLowerCase();
const first = needle[0];
let cursor = 0;

while (cursor < doc.length) {
const start = source.indexOf(first, cursor);
if (start === -1) break;

const match = findFuzzyMatchAt(doc, source, needle, start);
if (!match) break;

if (!options.wholeWord || isWholeWordMatch(doc, match.from, match.to)) {
matches.push(match);
cursor = match.to;
} else {
cursor = start + 1;
}
}

return matches;
}

export function findSearchMatches(
doc: string,
query: string,
Expand All @@ -259,6 +324,10 @@ export function findSearchMatches(
return [];
}

if (options.fuzzy && !options.regexp) {
return findFuzzySearchMatches(doc, query, options);
}

const pattern = buildSearchPattern(query, options);
if (!pattern) {
return [];
Expand Down Expand Up @@ -289,6 +358,19 @@ export function replaceAllMatches(
return doc;
}

if (options.fuzzy && !options.regexp) {
const matches = findFuzzySearchMatches(doc, query, options);
if (matches.length === 0) return doc;

let next = "";
let cursor = 0;
for (const match of matches) {
next += doc.slice(cursor, match.from) + replacement;
cursor = match.to;
}
return next + doc.slice(cursor);
}

const pattern = buildSearchPattern(query, options);
if (!pattern) {
return doc;
Expand Down
33 changes: 33 additions & 0 deletions packages/plugin-search/test/plugin-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,39 @@ describe("@floatboat/nexus-plugin-search", () => {
]);
});

it("supports fuzzy literal search as ordered subsequence matches", () => {
expect(findSearchMatches("Nexus note next edit", "ne", { fuzzy: true })).toEqual([
{ from: 0, to: 2, text: "Ne" },
{ from: 6, to: 10, text: "note" },
{ from: 11, to: 13, text: "ne" }
]);
});

it("supports case-sensitive fuzzy search", () => {
expect(findSearchMatches("Nexus next NE", "Ne", { fuzzy: true, caseSensitive: true })).toEqual([
{ from: 0, to: 2, text: "Ne" }
]);
});

it("supports whole-word fuzzy search", () => {
expect(findSearchMatches("fb f-b fxb", "fb", { fuzzy: true, wholeWord: true })).toEqual([
{ from: 0, to: 2, text: "fb" },
{ from: 3, to: 6, text: "f-b" },
{ from: 7, to: 10, text: "fxb" }
]);
expect(findSearchMatches("prefixfb", "fb", { fuzzy: true, wholeWord: true })).toEqual([]);
});

it("replaces fuzzy matches without overlapping ranges", () => {
expect(replaceAllMatches("Nexus note next", "ne", "X", { fuzzy: true })).toBe("Xxus X Xxt");
});

it("keeps regexp search semantics when fuzzy is also set", () => {
expect(findSearchMatches("foo123 bar", "\\d+", { fuzzy: true, regexp: true })).toEqual([
{ from: 3, to: 6, text: "123" }
]);
});

it("creates a search plugin descriptor", () => {
const plugin = createSearchPlugin();

Expand Down