1- import { TextSelection } from "prosemirror-state" ;
1+ import { Plugin , PluginKey , TextSelection } from "prosemirror-state" ;
2+ import { Decoration , DecorationSet } from "prosemirror-view" ;
23
34import type { Editor } from "../src/lib.ts" ;
45import { onLocaleChange , t , translateTree } from "./i18n.ts" ;
56
67type SearchMatch = { from : number ; to : number } ;
78
9+ type SearchHighlightMeta = {
10+ matches : SearchMatch [ ] ;
11+ activeIndex : number ;
12+ kind ?: "match" | "replacement" ;
13+ } ;
14+
15+ const searchHighlightKey = new PluginKey < DecorationSet > ( "website-search-highlights" ) ;
16+ const searchHighlightPlugin = new Plugin < DecorationSet > ( {
17+ key : searchHighlightKey ,
18+ state : {
19+ init : ( ) => DecorationSet . empty ,
20+ apply ( transaction , current , _oldState , newState ) {
21+ const meta = transaction . getMeta ( searchHighlightKey ) as SearchHighlightMeta | undefined ;
22+ if ( meta ) {
23+ const decorations = meta . matches
24+ . filter ( ( match ) => match . from < match . to && match . to <= newState . doc . content . size )
25+ . map ( ( match , index ) => Decoration . inline ( match . from , match . to , {
26+ class : [
27+ "editor-search-match" ,
28+ meta . kind === "replacement" ? "editor-search-replacement" : "" ,
29+ index === meta . activeIndex ? "editor-search-match-current" : "" ,
30+ ] . filter ( Boolean ) . join ( " " ) ,
31+ "data-search-match-index" : String ( index ) ,
32+ } ) ) ;
33+ return DecorationSet . create ( newState . doc , decorations ) ;
34+ }
35+ return transaction . docChanged
36+ ? current . map ( transaction . mapping , transaction . doc )
37+ : current ;
38+ } ,
39+ } ,
40+ props : {
41+ decorations ( state ) {
42+ return searchHighlightKey . getState ( state ) ?? DecorationSet . empty ;
43+ } ,
44+ } ,
45+ } ) ;
46+
847export type EditorSearch = {
948 open ( ) : void ;
1049 close ( ) : void ;
@@ -58,6 +97,86 @@ export function mountEditorSearch(main: HTMLElement, editor: Editor): EditorSear
5897 let matches : SearchMatch [ ] = [ ] ;
5998 let activeIndex = - 1 ;
6099
100+ const clearNodeViewHighlight = ( ) : void => {
101+ for ( const element of editor . view . dom . querySelectorAll ( ".editor-search-node-current" ) ) {
102+ element . classList . remove ( "editor-search-node-current" ) ;
103+ }
104+ } ;
105+
106+ const ensureHighlightPlugin = ( ) : void => {
107+ const view = editor . view ;
108+ if ( searchHighlightKey . getState ( view . state ) !== undefined ) return ;
109+ view . updateState ( view . state . reconfigure ( {
110+ plugins : [ ...view . state . plugins , searchHighlightPlugin ] ,
111+ } ) ) ;
112+ } ;
113+
114+ const updateHighlights = ( kind : SearchHighlightMeta [ "kind" ] = "match" ) : void => {
115+ clearNodeViewHighlight ( ) ;
116+ ensureHighlightPlugin ( ) ;
117+ editor . view . dispatch ( editor . view . state . tr . setMeta ( searchHighlightKey , {
118+ matches,
119+ activeIndex,
120+ kind,
121+ } satisfies SearchHighlightMeta ) ) ;
122+ } ;
123+
124+ const scrollElementToCenter = ( element : Element | null ) : boolean => {
125+ if ( ! ( element instanceof HTMLElement ) || typeof element . scrollIntoView !== "function" ) {
126+ return false ;
127+ }
128+ const bounds = element . getBoundingClientRect ( ) ;
129+ if ( bounds . width <= 0 && bounds . height <= 0 ) return false ;
130+ element . scrollIntoView ( {
131+ block : "center" ,
132+ inline : "nearest" ,
133+ behavior : "auto" ,
134+ } ) ;
135+ return true ;
136+ } ;
137+
138+ const scrollRenderedAncestorToCenter = ( start : Element | null ) : boolean => {
139+ let element = start ;
140+ while ( element ) {
141+ if ( scrollElementToCenter ( element ) ) {
142+ return true ;
143+ }
144+ element = element . parentElement ;
145+ }
146+ return false ;
147+ } ;
148+
149+ const scrollPositionToCenter = ( position : number , highlightNodeView = false ) : void => {
150+ try {
151+ const clamped = Math . max ( 0 , Math . min ( position , editor . view . state . doc . content . size ) ) ;
152+ const target = editor . view . domAtPos ( clamped ) . node ;
153+ const start = target instanceof Element ? target : target . parentElement ;
154+ const nodeView = highlightNodeView
155+ ? start ?. closest < HTMLElement > ( '[contenteditable="false"]' ) ?? null
156+ : null ;
157+ if ( nodeView ) {
158+ nodeView . classList . add ( "editor-search-node-current" ) ;
159+ if ( ! scrollElementToCenter ( nodeView ) ) scrollRenderedAncestorToCenter ( nodeView . parentElement ) ;
160+ return ;
161+ }
162+ scrollRenderedAncestorToCenter ( start ) ;
163+ } catch { }
164+ } ;
165+
166+ const scrollActiveMatchToCenter = ( match : SearchMatch ) : void => {
167+ clearNodeViewHighlight ( ) ;
168+ const highlighted = editor . view . dom . querySelector ( ".editor-search-match-current" ) ;
169+ if ( scrollElementToCenter ( highlighted ) ) return ;
170+ const nodeView = highlighted ?. closest < HTMLElement > ( '[contenteditable="false"]' ) ?? null ;
171+ if ( nodeView ) {
172+ nodeView . classList . add ( "editor-search-node-current" ) ;
173+ if ( ! scrollElementToCenter ( nodeView ) ) scrollRenderedAncestorToCenter ( nodeView . parentElement ) ;
174+ return ;
175+ }
176+ if ( scrollRenderedAncestorToCenter ( highlighted ?. parentElement ?? null ) ) return ;
177+ scrollPositionToCenter ( match . from , true ) ;
178+ } ;
179+
61180 const renderCount = ( replacementCount ?: number ) : void => {
62181 if ( replacementCount !== undefined ) {
63182 count . textContent = t ( "home.search.replaced" , { count : replacementCount } ) ;
@@ -77,29 +196,37 @@ export function mountEditorSearch(main: HTMLElement, editor: Editor): EditorSear
77196 matches = findMatches ( editor , queryInput . value ) ;
78197 if ( matches . length === 0 ) {
79198 activeIndex = - 1 ;
199+ updateHighlights ( ) ;
80200 renderCount ( ) ;
81201 return ;
82202 }
83203 activeIndex = ( ( index % matches . length ) + matches . length ) % matches . length ;
84204 const match = matches [ activeIndex ] ! ;
205+ ensureHighlightPlugin ( ) ;
85206 const transaction = editor . view . state . tr
86207 . setSelection ( TextSelection . create ( editor . view . state . doc , match . from , match . to ) )
208+ . setMeta ( searchHighlightKey , { matches, activeIndex } satisfies SearchHighlightMeta )
87209 . scrollIntoView ( ) ;
88210 editor . view . dispatch ( transaction ) ;
211+ scrollActiveMatchToCenter ( match ) ;
89212 renderCount ( ) ;
90213 } ;
91214
92215 const refresh = ( ) : void => {
93216 matches = findMatches ( editor , queryInput . value ) ;
94217 activeIndex = matches . length > 0 ? 0 : - 1 ;
95218 if ( activeIndex >= 0 ) selectMatch ( activeIndex ) ;
96- else renderCount ( ) ;
219+ else {
220+ updateHighlights ( ) ;
221+ renderCount ( ) ;
222+ }
97223 } ;
98224
99225 const replaceCurrent = ( ) : void => {
100226 matches = findMatches ( editor , queryInput . value ) ;
101227 if ( matches . length === 0 ) {
102228 activeIndex = - 1 ;
229+ updateHighlights ( ) ;
103230 renderCount ( ) ;
104231 return ;
105232 }
@@ -111,7 +238,13 @@ export function mountEditorSearch(main: HTMLElement, editor: Editor): EditorSear
111238 matches = findMatches ( editor , queryInput . value ) ;
112239 if ( matches . length > 0 ) selectMatch ( Math . min ( index , matches . length - 1 ) ) ;
113240 else {
114- activeIndex = - 1 ;
241+ matches = replacementInput . value
242+ ? [ { from : match . from , to : match . from + replacementInput . value . length } ]
243+ : [ ] ;
244+ activeIndex = matches . length > 0 ? 0 : - 1 ;
245+ updateHighlights ( "replacement" ) ;
246+ if ( matches [ 0 ] ) scrollActiveMatchToCenter ( matches [ 0 ] ) ;
247+ else scrollPositionToCenter ( match . from ) ;
115248 renderCount ( 1 ) ;
116249 }
117250 } ;
@@ -120,23 +253,39 @@ export function mountEditorSearch(main: HTMLElement, editor: Editor): EditorSear
120253 matches = findMatches ( editor , queryInput . value ) ;
121254 if ( matches . length === 0 ) {
122255 activeIndex = - 1 ;
256+ updateHighlights ( ) ;
123257 renderCount ( ) ;
124258 return ;
125259 }
126260 const replacementCount = matches . length ;
261+ const firstMatchPosition = matches [ 0 ] ! . from ;
262+ let offset = 0 ;
263+ const replacementMatches = replacementInput . value
264+ ? matches . map ( ( match ) => {
265+ const from = match . from + offset ;
266+ offset += replacementInput . value . length - ( match . to - match . from ) ;
267+ return { from, to : from + replacementInput . value . length } ;
268+ } )
269+ : [ ] ;
127270 let transaction = editor . view . state . tr ;
128271 for ( const match of [ ...matches ] . reverse ( ) ) {
129272 transaction = transaction . insertText ( replacementInput . value , match . from , match . to ) ;
130273 }
131274 editor . view . dispatch ( transaction . scrollIntoView ( ) ) ;
132- matches = findMatches ( editor , queryInput . value ) ;
133- activeIndex = - 1 ;
275+ matches = replacementMatches ;
276+ activeIndex = matches . length > 0 ? 0 : - 1 ;
277+ updateHighlights ( "replacement" ) ;
278+ if ( matches [ 0 ] ) scrollActiveMatchToCenter ( matches [ 0 ] ) ;
279+ else scrollPositionToCenter ( firstMatchPosition ) ;
134280 renderCount ( replacementCount ) ;
135281 } ;
136282
137283 const close = ( ) : void => {
138284 if ( panel . hidden ) return ;
139285 panel . hidden = true ;
286+ matches = [ ] ;
287+ activeIndex = - 1 ;
288+ updateHighlights ( ) ;
140289 editor . focus ( ) ;
141290 } ;
142291
@@ -191,6 +340,9 @@ export function mountEditorSearch(main: HTMLElement, editor: Editor): EditorSear
191340 } ,
192341 close,
193342 destroy ( ) : void {
343+ matches = [ ] ;
344+ activeIndex = - 1 ;
345+ updateHighlights ( ) ;
194346 cleanupLocale ( ) ;
195347 panel . removeEventListener ( "click" , onClick ) ;
196348 panel . removeEventListener ( "keydown" , onKeyDown ) ;
0 commit comments