@@ -9,6 +9,8 @@ export class Playground {
99 this . _mode = 'json' ;
1010 this . _jsonFormatter = new JsonFormatter ( ) ;
1111 this . _debounceTimer = null ;
12+ this . _searchMatches = [ ] ;
13+ this . _currentMatchIndex = - 1 ;
1214 }
1315
1416 render ( container ) {
@@ -156,6 +158,7 @@ export class Playground {
156158 <div class="playground-error-msg">${ escapeHtml ( err . message ) } </div>
157159 </div>` ;
158160 }
161+ this . _highlightOutputMatches ( ) ;
159162 }
160163
161164 _renderMarkdown ( raw ) {
@@ -178,6 +181,244 @@ export class Playground {
178181 <div class="playground-error-msg">${ escapeHtml ( err . message ) } </div>
179182 </div>` ;
180183 }
184+ this . _highlightOutputMatches ( ) ;
185+ }
186+
187+ // ── Search ────────────────────────────────────────────
188+
189+ /**
190+ * Search within the Playground textarea content.
191+ * Returns { matches: Array<{index, length, text}>, total: number }
192+ */
193+ search ( query ) {
194+ const raw = this . _input ? this . _input . value : '' ;
195+ if ( ! query || ! raw ) {
196+ this . _searchMatches = [ ] ;
197+ this . _currentMatchIndex = - 1 ;
198+ this . _clearHighlights ( ) ;
199+ return { matches : [ ] , total : 0 , current : - 1 } ;
200+ }
201+
202+ const queryLower = query . toLowerCase ( ) ;
203+ const rawLower = raw . toLowerCase ( ) ;
204+ this . _searchMatches = [ ] ;
205+
206+ let pos = 0 ;
207+ while ( pos < rawLower . length ) {
208+ const idx = rawLower . indexOf ( queryLower , pos ) ;
209+ if ( idx === - 1 ) break ;
210+ this . _searchMatches . push ( {
211+ index : idx ,
212+ length : query . length ,
213+ text : raw . substring ( idx , idx + query . length ) ,
214+ } ) ;
215+ pos = idx + 1 ;
216+ }
217+
218+ this . _currentMatchIndex = this . _searchMatches . length > 0 ? 0 : - 1 ;
219+ this . _highlightMatches ( ) ;
220+ return {
221+ matches : this . _searchMatches ,
222+ total : this . _searchMatches . length ,
223+ current : this . _currentMatchIndex ,
224+ } ;
225+ }
226+
227+ /**
228+ * Navigate to the next/previous search match in the textarea.
229+ * Direction: 1 = next, -1 = previous
230+ */
231+ navigateMatch ( direction ) {
232+ if ( this . _searchMatches . length === 0 ) return ;
233+ this . _currentMatchIndex += direction ;
234+ if ( this . _currentMatchIndex >= this . _searchMatches . length ) this . _currentMatchIndex = 0 ;
235+ if ( this . _currentMatchIndex < 0 ) this . _currentMatchIndex = this . _searchMatches . length - 1 ;
236+ this . _highlightMatches ( ) ;
237+ this . _scrollToCurrentMatch ( ) ;
238+ this . _updateCurrentOutputMark ( ) ;
239+ return this . _currentMatchIndex ;
240+ }
241+
242+ /**
243+ * Clear all search highlights and reset state.
244+ */
245+ clearSearch ( ) {
246+ this . _searchMatches = [ ] ;
247+ this . _currentMatchIndex = - 1 ;
248+ this . _clearHighlights ( ) ;
249+ this . _clearOutputHighlights ( ) ;
250+ }
251+
252+ /**
253+ * Highlight search matches in the output (rendered) panel.
254+ * Walks text nodes in the output DOM and wraps matching substrings with <mark>.
255+ */
256+ _highlightOutputMatches ( ) {
257+ if ( ! this . _output || this . _searchMatches . length === 0 ) return ;
258+
259+ const query = this . _searchMatches [ 0 ] ?. text ;
260+ if ( ! query ) return ;
261+
262+ const queryLower = query . toLowerCase ( ) ;
263+ const outputMarks = this . _output . querySelectorAll ( '.pg-output-mark' ) ;
264+ outputMarks . forEach ( ( mark ) => {
265+ const parent = mark . parentNode ;
266+ parent . replaceChild ( document . createTextNode ( mark . textContent ) , mark ) ;
267+ parent . normalize ( ) ;
268+ } ) ;
269+
270+ this . _outputMarkNodes = [ ] ;
271+ const walker = document . createTreeWalker (
272+ this . _output ,
273+ NodeFilter . SHOW_TEXT ,
274+ null ,
275+ ) ;
276+ const textNodes = [ ] ;
277+ while ( walker . nextNode ( ) ) {
278+ textNodes . push ( walker . currentNode ) ;
279+ }
280+
281+ for ( const node of textNodes ) {
282+ const text = node . textContent ;
283+ const textLower = text . toLowerCase ( ) ;
284+ let searchPos = 0 ;
285+
286+ const fragments = [ ] ;
287+ let lastIdx = 0 ;
288+
289+ while ( searchPos < textLower . length ) {
290+ const matchIdx = textLower . indexOf ( queryLower , searchPos ) ;
291+ if ( matchIdx === - 1 ) break ;
292+
293+ if ( matchIdx > lastIdx ) {
294+ fragments . push ( document . createTextNode ( text . substring ( lastIdx , matchIdx ) ) ) ;
295+ }
296+
297+ const markEl = document . createElement ( 'mark' ) ;
298+ markEl . className = 'pg-output-mark' ;
299+ markEl . textContent = text . substring ( matchIdx , matchIdx + query . length ) ;
300+ fragments . push ( markEl ) ;
301+ this . _outputMarkNodes . push ( markEl ) ;
302+
303+ lastIdx = matchIdx + query . length ;
304+ searchPos = matchIdx + 1 ;
305+ }
306+
307+ if ( fragments . length > 0 ) {
308+ if ( lastIdx < text . length ) {
309+ fragments . push ( document . createTextNode ( text . substring ( lastIdx ) ) ) ;
310+ }
311+ const parent = node . parentNode ;
312+ for ( const frag of fragments ) {
313+ parent . insertBefore ( frag , node ) ;
314+ }
315+ parent . removeChild ( node ) ;
316+ }
317+ }
318+
319+ // Mark the current match with an extra class
320+ if ( this . _currentMatchIndex >= 0 && this . _outputMarkNodes . length > 0 ) {
321+ // Map input matches to output marks — the nth mark in output corresponds to the nth match overall
322+ this . _updateCurrentOutputMark ( ) ;
323+ }
324+ }
325+
326+ _clearOutputHighlights ( ) {
327+ if ( ! this . _output ) return ;
328+ const marks = this . _output . querySelectorAll ( '.pg-output-mark' ) ;
329+ marks . forEach ( ( mark ) => {
330+ const parent = mark . parentNode ;
331+ parent . replaceChild ( document . createTextNode ( mark . textContent ) , mark ) ;
332+ parent . normalize ( ) ;
333+ } ) ;
334+ this . _outputMarkNodes = [ ] ;
335+ }
336+
337+ _updateCurrentOutputMark ( ) {
338+ // Remove previous current highlight from all output marks
339+ this . _outputMarkNodes . forEach ( ( m ) => m . classList . remove ( 'current' ) ) ;
340+
341+ if ( this . _currentMatchIndex < 0 || this . _outputMarkNodes . length === 0 ) return ;
342+
343+ // Find the output mark closest to the current match index
344+ // Each input match may map to 0, 1, or multiple output marks.
345+ // We pick the output mark whose text matches the same occurrence as the current input match.
346+ const currentInputMatch = this . _searchMatches [ this . _currentMatchIndex ] ;
347+ if ( ! currentInputMatch ) return ;
348+
349+ const matchedTextLower = currentInputMatch . text . toLowerCase ( ) ;
350+ for ( const mark of this . _outputMarkNodes ) {
351+ if ( mark . textContent . toLowerCase ( ) === matchedTextLower ) {
352+ mark . classList . add ( 'current' ) ;
353+ // Scroll output to show this mark
354+ mark . scrollIntoView ( { block : 'center' , behavior : 'smooth' } ) ;
355+ return ;
356+ }
357+ }
358+ }
359+
360+ _highlightMatches ( ) {
361+ this . _clearHighlights ( ) ;
362+
363+ if ( this . _searchMatches . length === 0 || ! this . _input ) return ;
364+
365+ // Create a mirrored overlay div for visual highlights over the textarea
366+ const wrap = this . _input . parentElement ;
367+ let overlay = wrap . querySelector ( '.playground-search-overlay' ) ;
368+ if ( ! overlay ) {
369+ overlay = document . createElement ( 'div' ) ;
370+ overlay . className = 'playground-search-overlay' ;
371+ wrap . appendChild ( overlay ) ;
372+
373+ // Sync scroll position from textarea to overlay
374+ this . _input . addEventListener ( 'scroll' , ( ) => {
375+ overlay . scrollTop = this . _input . scrollTop ;
376+ overlay . scrollLeft = this . _input . scrollLeft ;
377+ } ) ;
378+ }
379+
380+ // Build highlighted HTML from textarea content
381+ const raw = this . _input . value ;
382+ let html = '' ;
383+ let lastIdx = 0 ;
384+
385+ for ( let i = 0 ; i < this . _searchMatches . length ; i ++ ) {
386+ const match = this . _searchMatches [ i ] ;
387+ html += escapeHtml ( raw . substring ( lastIdx , match . index ) ) ;
388+ const isCurrent = i === this . _currentMatchIndex ;
389+ html += `<mark class="playground-search-highlight${ isCurrent ? ' current' : '' } ">${ escapeHtml ( match . text ) } </mark>` ;
390+ lastIdx = match . index + match . length ;
391+ }
392+ html += escapeHtml ( raw . substring ( lastIdx ) ) ;
393+
394+ overlay . innerHTML = html ;
395+
396+ this . _scrollToCurrentMatch ( ) ;
397+ this . _highlightOutputMatches ( ) ;
398+ }
399+
400+ _clearHighlights ( ) {
401+ const wrap = this . _input ? this . _input . parentElement : null ;
402+ if ( ! wrap ) return ;
403+ const overlay = wrap . querySelector ( '.playground-search-overlay' ) ;
404+ if ( overlay ) overlay . innerHTML = '' ;
405+ }
406+
407+ _scrollToCurrentMatch ( ) {
408+ if ( this . _currentMatchIndex < 0 || ! this . _input ) return ;
409+ const match = this . _searchMatches [ this . _currentMatchIndex ] ;
410+
411+ // Calculate approximate scroll position based on character index
412+ const textBeforeMatch = this . _input . value . substring ( 0 , match . index ) ;
413+ const linesBeforeMatch = textBeforeMatch . split ( '\n' ) . length - 1 ;
414+ const lineHeight = parseFloat ( getComputedStyle ( this . _input ) . lineHeight ) || 21 ;
415+ const scrollTop = linesBeforeMatch * lineHeight - this . _input . clientHeight / 2 ;
416+
417+ this . _input . scrollTop = Math . max ( 0 , scrollTop ) ;
418+
419+ // Also scroll the overlay
420+ const overlay = this . _input . parentElement . querySelector ( '.playground-search-overlay' ) ;
421+ if ( overlay ) overlay . scrollTop = this . _input . scrollTop ;
181422 }
182423
183424 destroy ( ) {
0 commit comments