Skip to content

[lexical-playground] Feature: Find and Replace#8779

Merged
etrepum merged 10 commits into
facebook:mainfrom
mayrang:feat/8776-find-replace
Jul 4, 2026
Merged

[lexical-playground] Feature: Find and Replace#8779
etrepum merged 10 commits into
facebook:mainfrom
mayrang:feat/8776-find-replace

Conversation

@mayrang

@mayrang mayrang commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Description

Adds a find-and-replace panel to the playground. The panel opens via Ctrl+F (find) or Cmd+Option+F (find and replace), and supports case-sensitive search, regex mode, match navigation, single replace, and replace-all.

Search runs against registerTextContentListener's cached text — no tree traversal per keystroke, following the direction suggested in #8776. When matches exist, $buildOffsetMap walks the tree once via $dfsWithSlotsIterator to map global text offsets back to individual TextNode keys and local offsets. Highlights render through the CSS Custom Highlight API (::highlight()) on modern browsers, with a positioned-overlay-span fallback for browsers that don't support it. The dual-path approach mirrors @lexical/yjs SyncCursors.ts.

Replace uses select() + setTextNodeRange() + insertText() so that single-node replacements preserve the original text format (bold, italic, etc.). Replace-all processes matches back-to-front in a single editor.update() so earlier offsets stay valid.

What changed (review feedback)

1. Logic moved from React to Extension (namedSignals / commands)

All search state (searchTerm, replaceTerm, caseSensitive, isRegex, currentIndex, isOpen) lives in namedSignals inside FindReplaceExtension.build(). Derived values (matches, cachedText) use watchedSignal and computed. Side effects (highlight rendering, match navigation, replace) run in effect() inside register(). The React panel is now a thin view — useExtensionSignalValue for reads, editor.dispatchCommand() for writes. The only remaining React hook is useRef for the search input focus ref.

2. Regex capture group replacement ($1, $2, $&)

expandReplacement(template, matchText, searchRegex) delegates to String.prototype.replace, so $1/$2/$&/$$ all work natively. findMatches now returns matchText per match. Non-regex mode treats $1 as a literal.

3. Panel keyboard shortcuts

Shortcuts that previously only worked with editor focus now also work when the panel itself has focus, via onKeyDown on the panel container:

Shortcut Action
Cmd+G / Ctrl+G Next match
Shift+Cmd+G / Shift+Ctrl+G Previous match
Cmd+F / Ctrl+F Focus search input (blocks browser find)
Cmd+Option+F Toggle panel (blocks browser default)
Enter / Shift+Enter Next / previous match
Escape Close panel

4. Scroll into view

When navigating to a match that's off-screen, the match element scrolls into view (scrollIntoView({behavior: 'smooth', block: 'nearest'})). Works in both the CSS Highlights path and the overlay-span fallback.

Design notes

  • The offset map assumes getTextContent() inserts \n\n between sibling block elements and \n for LineBreakNode. If this convention changes in core, the map would need updating.
  • Highlight names include editor.getKey() to avoid CSS.highlights collisions when multiple editors share a document.
  • Invalid regex patterns show "Invalid regex" in the match counter rather than a silent "No results".

Closes #8776

Test plan

  • pnpm vitest run --project unit -- FindReplace — 46 tests pass:
    • 14 findMatches + 3 zero-length regex edge cases (.*, a*, x?)
    • 7 $buildOffsetMap
    • 3 $resolveMatchToPoints
    • 6 expandReplacement ($1/$2, $&, $$, non-regex literal, no-group fallback)
    • 5 $replaceMatch (basic, format preservation, empty string, regex capture group, non-regex $1 literal)
    • 4 $replaceAllMatches (basic, shorter, longer, regex capture group)
    • 4 Extension command dispatch integration (toggle, close, matches recompute, next/prev cycle)
  • e2e regression: Ctrl+H binding removed — deleteBackward tests pass (previously intercepted by the Ctrl+H handler)
  • tsc / eslint / prettier clean
  • Manual playground (Chrome, Firefox, Safari):
    • S1. Open/close: Cmd+F opens panel with search focused, Cmd+Option+F opens panel, Escape closes + clears highlights + restores editor focus, X button same as Escape
    • S2. Search + highlight: single match "1 / 1", multiple matches "1 / 3" with first in orange, no match "No results", live count update on text edit
    • S3. Next/previous navigation: Enter advances (1/3 → 2/3), Shift+Enter goes back, Cmd+G / Shift+Cmd+G from panel focus, Cmd+G from editor focus, wraps at end (3/3 → 1/3)
    • S4. Panel focus shortcuts: Cmd+F re-focuses search input (blocks browser find), Cmd+Option+F re-focuses (blocks browser default)
    • S5. Case sensitive / regex toggles: Aa toggle "Hello HELLO hello" → 3 → 1, regex toggle \d+ → "No results" → "1 / 2", invalid regex [invalid → "Invalid regex"
    • S6. Single replace: "hello" → "goodbye" preserves surrounding text, empty replacement deletes matched text
    • S7. Replace all: 3 × "foo" → "qux" in one operation
    • S8. Regex capture groups: (\w+)@(\w+) + $2/$1 → "user@host" → "host/user", $& whole-match reference, capture group replace-all, non-regex $1 stays literal
    • S9. Formatted text: bold+plain boundary search highlights across nodes, replace inside bold preserves bold format
    • S10. Complex structures: multi-paragraph search, list item search
    • S11. Scroll into view: navigating to off-screen match scrolls it into the viewport

@vercel

vercel Bot commented Jul 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview Jul 4, 2026 9:31pm
lexical-playground Ready Ready Preview Jul 4, 2026 9:31pm

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jul 3, 2026
@mayrang mayrang marked this pull request as ready for review July 3, 2026 14:57
@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Jul 3, 2026

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is very cool! For regex find+replace I would expect $1, $2, etc. in the replacement to reference the groups in the source

export const FindReplaceExtension = /* @__PURE__ */ defineExtension({
name: '@lexical/playground/FindReplace',
register: editor => {
return editor.registerCommand(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This works while the editor is selected but when in the find & replace dialog itself it defers to the native implementation. Might need to have a parallel set attached to the panel.

Probably also makes sense to register the other common shortcuts related to find & replace, particularly Cmd+G and Shift+Cmd+G for find next/previous but maybe some of the others? Screenshot from macOS Chrome

Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — panel onKeyDown handles Cmd+G / Shift+Cmd+G, Cmd+F, Ctrl+H (non-macOS) / Cmd+Option+F, Enter / Shift+Enter, and Escape. Ctrl+H skipped on macOS (Emacs deleteBackward).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correction: removed Ctrl+H binding entirely. macOS maps Ctrl+H to Emacs deleteBackward, and Playwright sends it as the deleteBackward shortcut — the binding broke 9 e2e tests. Only Cmd+F (find) and Cmd+Option+F (find+replace) open the panel now.

// React UI
// ---------------------------------------------------------------------------

function FindReplacePanel({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It would be a better abstraction if this was really just the UI (none of the useEffect boilerplate here) and the logic was in the extension, maybe facilitated by commands. Would be easier to port.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — all state in namedSignals, derived values via watchedSignal/computed, side effects in effect(). Panel is useExtensionSignalValue + dispatchCommand, only remaining hook is useRef.

Add a find-and-replace panel to the playground, activated via Ctrl+F / Ctrl+H / Cmd+Option+F. Uses the CSS Custom Highlight API for zero-DOM-overhead match rendering, with a legacy overlay-span fallback.

Closes facebook#8776
…als, capture groups, panel shortcuts

Address etrepum's 3 review items on PR facebook#8779:

1. Regex $1/$2 capture group support via expandReplacement() using native String.prototype.replace
2. Panel keyboard shortcuts (Cmd+G, Shift+Cmd+G, Cmd+F, Cmd+Option+F) work in both editor and panel focus
3. Move all logic from React to Extension (namedSignals/computed/watchedSignal/effect + commands)

Fix macOS Ctrl+H conflict: guard Ctrl+H (open find-replace) with !IS_APPLE since macOS uses Ctrl+H for Emacs-style deleteBackward.

Additional improvements:
- Replace $createRangeSelection() anti-pattern with select() + setTextNodeRange()
- Widen find-replace-count min-width to prevent layout shift between result states
@mayrang

mayrang commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Done — expandReplacement delegates to String.prototype.replace so $1/$2/$&/$$ all work natively. Non-regex mode treats $1 as a literal.

Comment on lines +72 to +74
const EMPTY_MATCHES: TextMatch[] = Object.freeze<TextMatch[]>(
[],
) as TextMatch[];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Probably better in this case to just return [] from findMatches rather than to lie and say that this readonly value is actually mutable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — returning [] directly.

});

const cachedText = watchedSignal(
() => editor.read(() => $getRoot().getTextContent()),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think we want this to force a commit

Suggested change
() => editor.read(() => $getRoot().getTextContent()),
() => editor.read('latest', () => $getRoot().getTextContent()),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — switched to editor.read('latest', ...).

editor.registerCommand(
KEY_DOWN_COMMAND,
event => {
const isFindReplace =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These would probably be a little better with isExactShortcutMatch and/or isModifierMatch

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — extracted a CONTROL_OR_META constant and switched both KEY_DOWN_COMMAND and panel onKeyDown to isExactShortcutMatch.

);
return;
}
const isMod = IS_APPLE ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks like more code that could benefit from isExactShortcutMatch

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Covered in the same change as above.

…tcutMatch, read('latest'), drop EMPTY_MATCHES cast
…, required params, test.for, integration tests

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Overall this works great, here's two small improvements I think would be nice before merge:

The replace algorithm should pick the smallest replacement target in terms of document coordinates, e.g. if you open https://lexical-playground-git-fork-mayrang-feat-87-a6242b-fbopensource.vercel.app/?emptyEditor=false and replace "repository" with "repo" it lifts the TextNode out of the LinkNode (same behavior for replacing at the leading edge of a LinkNode). I would expect it to replace just the text so "repo" is still in the LinkNode.

The tab index could match a more typical find & replace dialog's order, most importantly I expect pressing tab from "find" to take me to "replace" before visiting the other controls. vscode screen recording below

find-replace-tab-index.mov

mayrang added 3 commits July 5, 2026 06:17
…order

Use spliceText for single-node matches so replacement text stays
inside its parent ElementNode (e.g. LinkNode) instead of being
lifted out by selection.insertText.

Switch panel layout to CSS Grid so DOM order (= tab order) matches
VSCode convention (Find → Replace → Aa → .* → Prev → Next →
Replace → All → Close) while grid placement keeps the original
visual arrangement. Add Tab key interceptor for Safari
compatibility and focus trapping within the panel.
@mayrang

mayrang commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Both fixed. Single-node matches now use spliceText so the replacement stays inside its parent (LinkNode etc.) instead of being lifted out by selection.insertText. Panel layout switched to CSS Grid — DOM order controls tab sequence (Find → Replace → Aa → .* → Prev → Next → Replace → All → Close) while grid placement keeps the visual arrangement. Added a Tab key interceptor for Safari since it skips buttons by default.

@etrepum etrepum added this pull request to the merge queue Jul 4, 2026
@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 4, 2026
@etrepum etrepum added this pull request to the merge queue Jul 4, 2026
Merged via the queue into facebook:main with commit 9d5ae70 Jul 4, 2026
46 checks passed
@etrepum etrepum mentioned this pull request Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Find and Replace

2 participants