Skip to content

Commit 18be643

Browse files
committed
feat: 新增 playground 查询功能
1 parent 40ff2c6 commit 18be643

3 files changed

Lines changed: 450 additions & 4 deletions

File tree

src/components/playground.js

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)