[lexical-playground] Feature: Find and Replace#8779
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
etrepum
left a comment
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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({ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
707ad0a to
ebb8fc3
Compare
…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
…s + regex edge case tests
|
Done — |
| const EMPTY_MATCHES: TextMatch[] = Object.freeze<TextMatch[]>( | ||
| [], | ||
| ) as TextMatch[]; |
There was a problem hiding this comment.
Probably better in this case to just return [] from findMatches rather than to lie and say that this readonly value is actually mutable.
There was a problem hiding this comment.
Done — returning [] directly.
| }); | ||
|
|
||
| const cachedText = watchedSignal( | ||
| () => editor.read(() => $getRoot().getTextContent()), |
There was a problem hiding this comment.
I don't think we want this to force a commit
| () => editor.read(() => $getRoot().getTextContent()), | |
| () => editor.read('latest', () => $getRoot().getTextContent()), |
There was a problem hiding this comment.
Done — switched to editor.read('latest', ...).
| editor.registerCommand( | ||
| KEY_DOWN_COMMAND, | ||
| event => { | ||
| const isFindReplace = |
There was a problem hiding this comment.
These would probably be a little better with isExactShortcutMatch and/or isModifierMatch
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
Looks like more code that could benefit from isExactShortcutMatch
There was a problem hiding this comment.
Covered in the same change as above.
…tcutMatch, read('latest'), drop EMPTY_MATCHES cast
…flicts with macOS deleteBackward)
f50b945 to
d9f9c0e
Compare
…, required params, test.for, integration tests
etrepum
left a comment
There was a problem hiding this comment.
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
…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.
|
Both fixed. Single-node matches now use |
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,$buildOffsetMapwalks the tree once via$dfsWithSlotsIteratorto 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/yjsSyncCursors.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 singleeditor.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 innamedSignalsinsideFindReplaceExtension.build(). Derived values (matches,cachedText) usewatchedSignalandcomputed. Side effects (highlight rendering, match navigation, replace) run ineffect()insideregister(). The React panel is now a thin view —useExtensionSignalValuefor reads,editor.dispatchCommand()for writes. The only remaining React hook isuseReffor the search input focus ref.2. Regex capture group replacement (
$1,$2,$&)expandReplacement(template, matchText, searchRegex)delegates toString.prototype.replace, so$1/$2/$&/$$all work natively.findMatchesnow returnsmatchTextper match. Non-regex mode treats$1as a literal.3. Panel keyboard shortcuts
Shortcuts that previously only worked with editor focus now also work when the panel itself has focus, via
onKeyDownon the panel container: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
getTextContent()inserts\n\nbetween sibling block elements and\nforLineBreakNode. If this convention changes in core, the map would need updating.editor.getKey()to avoidCSS.highlightscollisions when multiple editors share a document.Closes #8776
Test plan
pnpm vitest run --project unit -- FindReplace— 46 tests pass:findMatches+ 3 zero-length regex edge cases (.*,a*,x?)$buildOffsetMap$resolveMatchToPointsexpandReplacement($1/$2,$&,$$, non-regex literal, no-group fallback)$replaceMatch(basic, format preservation, empty string, regex capture group, non-regex$1literal)$replaceAllMatches(basic, shorter, longer, regex capture group)Ctrl+Hbinding removed —deleteBackwardtests pass (previously intercepted by theCtrl+Hhandler)\d+→ "No results" → "1 / 2", invalid regex[invalid→ "Invalid regex"(\w+)@(\w+)+$2/$1→ "user@host" → "host/user",$&whole-match reference, capture group replace-all, non-regex$1stays literal