Skip to content

Commit 9e1a11d

Browse files
authored
Add CodeMirror extension to display find match count in the editor. (#390)
1 parent 52732e1 commit 9e1a11d

File tree

3 files changed

+159
-3
lines changed

3 files changed

+159
-3
lines changed

src-web/components/core/Editor/Editor.css

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -434,16 +434,55 @@
434434

435435
input {
436436
@apply bg-surface border-border-subtle focus:border-border-focus;
437-
@apply border outline-none cursor-text;
437+
@apply border outline-none;
438438
}
439439

440-
label {
441-
@apply focus-within:text-text;
440+
input.cm-textfield {
441+
@apply cursor-text;
442+
}
443+
444+
.cm-search label {
445+
@apply inline-flex items-center h-6 px-1.5 rounded-sm border border-border-subtle cursor-default text-text-subtle text-xs;
446+
447+
input[type="checkbox"] {
448+
@apply hidden;
449+
}
450+
451+
&:has(:checked) {
452+
@apply text-primary border-border;
453+
}
442454
}
443455

444456
/* Hide the "All" button */
445457

446458
button[name="select"] {
447459
@apply hidden;
448460
}
461+
462+
/* Replace next/prev button text with chevron icons */
463+
464+
.cm-search button[name="next"],
465+
.cm-search button[name="prev"] {
466+
@apply text-[0px] w-7 h-6 inline-flex items-center justify-center border border-border-subtle mr-1;
467+
}
468+
469+
.cm-search button[name="prev"]::after,
470+
.cm-search button[name="next"]::after {
471+
@apply block w-3.5 h-3.5 bg-text;
472+
content: "";
473+
}
474+
475+
.cm-search button[name="prev"]::after {
476+
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E");
477+
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E");
478+
}
479+
480+
.cm-search button[name="next"]::after {
481+
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E");
482+
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E");
483+
}
484+
485+
.cm-search-match-count {
486+
@apply text-text-subtle text-xs font-mono whitespace-nowrap px-1.5 py-0.5 self-center;
487+
}
449488
}

src-web/components/core/Editor/extensions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import type { TwigCompletionOption } from './twig/completion';
6767
import { twig } from './twig/extension';
6868
import { pathParametersPlugin } from './twig/pathParameters';
6969
import { url } from './url/extension';
70+
import { searchMatchCount } from './searchMatchCount';
7071

7172
export const syntaxHighlightStyle = HighlightStyle.define([
7273
{
@@ -256,6 +257,7 @@ export const readonlyExtensions = [
256257

257258
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
258259
search({ top: true }),
260+
searchMatchCount(),
259261
hideGutter
260262
? []
261263
: [
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { getSearchQuery, searchPanelOpen } from '@codemirror/search';
2+
import type { Extension } from '@codemirror/state';
3+
import { type EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
4+
5+
/**
6+
* A CodeMirror extension that displays the total number of search matches
7+
* inside the built-in search panel.
8+
*/
9+
export function searchMatchCount(): Extension {
10+
return ViewPlugin.fromClass(
11+
class {
12+
private countEl: HTMLElement | null = null;
13+
14+
constructor(private view: EditorView) {
15+
this.updateCount();
16+
}
17+
18+
update(update: ViewUpdate) {
19+
// Recompute when doc changes, search state changes, or selection moves
20+
const query = getSearchQuery(update.state);
21+
const prevQuery = getSearchQuery(update.startState);
22+
const open = searchPanelOpen(update.state);
23+
const prevOpen = searchPanelOpen(update.startState);
24+
25+
if (update.docChanged || update.selectionSet || !query.eq(prevQuery) || open !== prevOpen) {
26+
this.updateCount();
27+
}
28+
}
29+
30+
private updateCount() {
31+
const state = this.view.state;
32+
const open = searchPanelOpen(state);
33+
const query = getSearchQuery(state);
34+
35+
if (!open) {
36+
this.removeCountEl();
37+
return;
38+
}
39+
40+
this.ensureCountEl();
41+
42+
if (!query.search) {
43+
if (this.countEl) {
44+
this.countEl.textContent = '0/0';
45+
}
46+
return;
47+
}
48+
49+
const selection = state.selection.main;
50+
let count = 0;
51+
let currentIndex = 0;
52+
const MAX_COUNT = 9999;
53+
const cursor = query.getCursor(state);
54+
while (!cursor.next().done) {
55+
count++;
56+
if (cursor.value.from <= selection.from && cursor.value.to >= selection.to) {
57+
currentIndex = count;
58+
}
59+
if (count > MAX_COUNT) break;
60+
}
61+
62+
if (this.countEl) {
63+
if (count > MAX_COUNT) {
64+
this.countEl.textContent = `${MAX_COUNT}+`;
65+
} else if (count === 0) {
66+
this.countEl.textContent = '0/0';
67+
} else if (currentIndex > 0) {
68+
this.countEl.textContent = `${currentIndex}/${count}`;
69+
} else {
70+
this.countEl.textContent = `0/${count}`;
71+
}
72+
}
73+
}
74+
75+
private ensureCountEl() {
76+
// Find the search panel in the editor DOM
77+
const panel = this.view.dom.querySelector('.cm-search');
78+
if (!panel) {
79+
this.countEl = null;
80+
return;
81+
}
82+
83+
if (this.countEl && this.countEl.parentElement === panel) {
84+
return; // Already attached
85+
}
86+
87+
this.countEl = document.createElement('span');
88+
this.countEl.className = 'cm-search-match-count';
89+
90+
// Reorder: insert prev button, then next button, then count after the search input
91+
const searchInput = panel.querySelector('input');
92+
const prevBtn = panel.querySelector('button[name="prev"]');
93+
const nextBtn = panel.querySelector('button[name="next"]');
94+
if (searchInput && searchInput.parentElement === panel) {
95+
searchInput.after(this.countEl);
96+
if (prevBtn) this.countEl.after(prevBtn);
97+
if (nextBtn && prevBtn) prevBtn.after(nextBtn);
98+
} else {
99+
panel.prepend(this.countEl);
100+
}
101+
}
102+
103+
private removeCountEl() {
104+
if (this.countEl) {
105+
this.countEl.remove();
106+
this.countEl = null;
107+
}
108+
}
109+
110+
destroy() {
111+
this.removeCountEl();
112+
}
113+
},
114+
);
115+
}

0 commit comments

Comments
 (0)