Skip to content

Commit bc14e79

Browse files
authored
Merge pull request #4236 from processing/develop-search-ui
Fix search / find panel
2 parents c636096 + b14bbb0 commit bc14e79

13 files changed

Lines changed: 569 additions & 132 deletions

File tree

client/images/arrow.svg

Lines changed: 1 addition & 0 deletions
Loading

client/images/match-case.svg

Lines changed: 1 addition & 0 deletions
Loading

client/images/regex.svg

Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import PropTypes from 'prop-types';
2+
import React, { useState, useEffect, useRef, useCallback } from 'react';
3+
import {
4+
SearchQuery,
5+
setSearchQuery,
6+
findNext,
7+
findPrevious,
8+
replaceNext,
9+
replaceAll,
10+
openSearchPanel,
11+
closeSearchPanel,
12+
getSearchQuery
13+
} from '@codemirror/search';
14+
import { runScopeHandlers } from '@codemirror/view';
15+
import MatchCaseSvg from '../../../../images/match-case.svg';
16+
import RegexSvg from '../../../../images/regex.svg';
17+
import ArrowSvg from '../../../../images/arrow.svg';
18+
import CrossSvg from '../../../../images/cross.svg';
19+
import CaretArrowSvg from '../../../../images/right-arrow.svg';
20+
21+
/**
22+
* Custom implementation of CodeMirror 6's built-in SearchPanel.
23+
*
24+
* TODO:
25+
* We may need to add an announce functionality for screen readers, similar to the original:
26+
* https://github.com/codemirror/search/blob/4db1811530f9e77239e5de8bd7bb155ffc0377e0/src/search.ts#L766
27+
*
28+
* It may also be nice to have a X of X label for results, similar to vscode.
29+
*/
30+
export default function SearchPanel({ view, closePanel, isMobile }) {
31+
// Seed initial state from whatever query is already active in the editor,
32+
// falling back to an empty query.
33+
const initialQuery = view
34+
? getSearchQuery(view.state)
35+
: new SearchQuery({ search: '' });
36+
37+
const [search, setSearch] = useState(initialQuery.search);
38+
const [replace, setReplace] = useState(initialQuery.replace);
39+
const [caseSensitive, setCaseSensitive] = useState(
40+
initialQuery.caseSensitive
41+
);
42+
const [regexp, setRegexp] = useState(initialQuery.regexp);
43+
const [showReplace, setShowReplace] = useState(false);
44+
45+
const searchFieldRef = useRef(null);
46+
const replaceFieldRef = useRef(null);
47+
48+
// Keep a ref of the "last committed" query so we can avoid redundant dispatches,
49+
// same as the original's `this.query` comparison.
50+
const lastQueryRef = useRef(initialQuery);
51+
52+
useEffect(() => {
53+
if (view) {
54+
openSearchPanel(view);
55+
}
56+
57+
return () => {
58+
if (view) {
59+
closeSearchPanel(view);
60+
view.focus();
61+
}
62+
};
63+
}, [view]);
64+
65+
// Push the current form state into the editor as a SearchQuery.
66+
const commit = useCallback(() => {
67+
if (!view) return;
68+
const query = new SearchQuery({
69+
search,
70+
caseSensitive,
71+
regexp,
72+
replace
73+
});
74+
if (!query.eq(lastQueryRef.current)) {
75+
lastQueryRef.current = query;
76+
view.dispatch({ effects: setSearchQuery.of(query) });
77+
}
78+
}, [view, search, replace, caseSensitive, regexp]);
79+
80+
// Re-commit whenever any field changes (mirrors onchange/onkeyup -> commit()).
81+
useEffect(() => {
82+
commit();
83+
}, [commit]);
84+
85+
// Focus + select the search field on mount, like the original's mount().
86+
useEffect(() => {
87+
searchFieldRef.current?.select();
88+
}, []);
89+
90+
const handleKeyDown = (e) => {
91+
if (view && runScopeHandlers(view, e.nativeEvent, 'search-panel')) {
92+
e.preventDefault();
93+
return;
94+
}
95+
if (e.keyCode === 13 && e.target === searchFieldRef.current) {
96+
e.preventDefault();
97+
if (view) (e.shiftKey ? findPrevious : findNext)(view);
98+
} else if (e.keyCode === 13 && e.target === replaceFieldRef.current) {
99+
e.preventDefault();
100+
if (view) replaceNext(view);
101+
} else if (e.keyCode === 27) {
102+
// Escape closes the panel
103+
e.preventDefault();
104+
closePanel();
105+
}
106+
};
107+
108+
return (
109+
<>
110+
{/* The <div> element TODO */}
111+
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
112+
<div
113+
className={`cm-search-panel ${isMobile ? 'mobile' : ''}`}
114+
onKeyDown={handleKeyDown}
115+
>
116+
<button
117+
type="button"
118+
aria-label="close"
119+
onClick={closePanel}
120+
className="cm-search-close"
121+
>
122+
<CrossSvg focusable="false" aria-hidden="true" />
123+
</button>
124+
<button
125+
type="button"
126+
className="cm-search-toggle-replace"
127+
onClick={() => setShowReplace((s) => !s)}
128+
aria-expanded={showReplace}
129+
aria-controls="cm-search-replace-row"
130+
title={showReplace ? 'Hide replace' : 'Show replace'}
131+
>
132+
<CaretArrowSvg
133+
aria-hidden="true"
134+
style={{
135+
transform: showReplace ? 'rotate(90deg)' : 'rotate(0deg)',
136+
transition: 'transform 0.18s ease'
137+
}}
138+
/>
139+
</button>
140+
<div className="cm-search-content">
141+
<div className="cm-search-row cm-search-find">
142+
<div className="cm-search-findContainer">
143+
<input
144+
ref={searchFieldRef}
145+
type="text"
146+
name="search"
147+
className="cm-textfield"
148+
placeholder="Find"
149+
aria-label="Find"
150+
value={search}
151+
onChange={(e) => setSearch(e.target.value)}
152+
/>
153+
<div className="cm-search-findOptions">
154+
<label htmlFor="cm-search-caseSensitive">
155+
<input
156+
id="cm-search-caseSensitive"
157+
type="checkbox"
158+
checked={caseSensitive}
159+
onChange={(e) => setCaseSensitive(e.target.checked)}
160+
/>
161+
<MatchCaseSvg focusable="false" aria-hidden="true" />
162+
</label>
163+
<label htmlFor="cm-search-regexp">
164+
<input
165+
id="cm-search-regexp"
166+
type="checkbox"
167+
checked={regexp}
168+
onChange={(e) => setRegexp(e.target.checked)}
169+
/>
170+
<RegexSvg focusable="false" aria-hidden="true" />
171+
</label>
172+
</div>
173+
</div>
174+
<button
175+
type="button"
176+
className="cm-search-button cm-search-next"
177+
onClick={() => view && findNext(view)}
178+
>
179+
<ArrowSvg aria-label="Find next search" />
180+
</button>
181+
<button
182+
type="button"
183+
className="cm-search-button cm-search-previous"
184+
onClick={() => view && findPrevious(view)}
185+
>
186+
<ArrowSvg aria-label="Find previous search" />
187+
</button>
188+
</div>
189+
{showReplace && (
190+
<div
191+
id="cm-search-replace-row"
192+
className="cm-search-row cm-search-replace"
193+
>
194+
<input
195+
ref={replaceFieldRef}
196+
type="text"
197+
name="replace"
198+
className="cm-textfield"
199+
placeholder="Replace"
200+
aria-label="Replace"
201+
value={replace}
202+
onChange={(e) => setReplace(e.target.value)}
203+
/>
204+
<button
205+
className="cm-search-button"
206+
onClick={() => {
207+
console.log('replace next');
208+
if (view) replaceNext(view);
209+
}}
210+
>
211+
replace
212+
</button>
213+
<button
214+
className="cm-search-button"
215+
onClick={() => view && replaceAll(view)}
216+
>
217+
replace all
218+
</button>
219+
</div>
220+
)}
221+
</div>
222+
</div>
223+
</>
224+
);
225+
}
226+
227+
SearchPanel.propTypes = {
228+
view: PropTypes.shape({
229+
dispatch: PropTypes.func.isRequired,
230+
state: PropTypes.shape({}),
231+
focus: PropTypes.func.isRequired
232+
}),
233+
closePanel: PropTypes.func.isRequired,
234+
isMobile: PropTypes.bool
235+
};
236+
237+
SearchPanel.defaultProps = {
238+
view: null,
239+
isMobile: false
240+
};

client/modules/IDE/components/Editor/codemirror.js

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { useRef, useEffect } from 'react';
22
import { EditorView, lineNumbers as lineNumbersExt } from '@codemirror/view';
33
import { autocompletion, closeBrackets } from '@codemirror/autocomplete';
44
import { debounce } from 'lodash';
5-
import { openSearchPanel } from '@codemirror/search';
65
import { saveLocalBackup } from '../../utils/localBackup';
76
import { p5JavaScript } from './utils/p5JavaScript';
87

@@ -39,7 +38,8 @@ export default function useCodeMirror({
3938
fontSize,
4039
onUpdateLinting,
4140
referenceBaseUrl,
42-
p5Version
41+
p5Version,
42+
showSearch
4343
}) {
4444
// The codemirror instance.
4545
const cmView = useRef();
@@ -208,7 +208,8 @@ export default function useCodeMirror({
208208
onViewUpdate,
209209
referenceBaseUrl,
210210
fontSize,
211-
p5Version
211+
p5Version,
212+
searchPanelCallback: showSearch
212213
}
213214
);
214215
}
@@ -241,10 +242,6 @@ export default function useCodeMirror({
241242
return updatedFile;
242243
};
243244

244-
const showSearch = () => {
245-
openSearchPanel(cmView.current);
246-
};
247-
248245
const tidyCode = () => {
249246
const fileMode = getFileMode(file.name);
250247
tidyCodeWithPrettier(cmView.current, fileMode);
@@ -273,7 +270,6 @@ export default function useCodeMirror({
273270
teardownCodeMirror,
274271
getContent,
275272
tidyCode,
276-
showSearch,
277273
updateEditorFileContent,
278274
codemirrorView: cmView
279275
};

client/modules/IDE/components/Editor/index.jsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import AssetPreview from '../AssetPreview';
2727
import Timer from '../Timer';
2828
import EditorAccessibility from '../EditorAccessibility';
2929
import UnsavedChangesIndicator from '../UnsavedChangesIndicator';
30+
import SearchPanel from './SearchPanel';
3031
import { EditorContainer, EditorHolder } from './MobileEditor';
3132
import { FolderIcon } from '../../../../common/icons';
3233
import { IconButton } from '../../../../common/IconButton';
@@ -81,6 +82,7 @@ function Editor({
8182
}) {
8283
const { versionInfo } = useP5Version();
8384
const [currentLine, setCurrentLine] = useState(1);
85+
const [isSearchOpen, setIsSearchOpen] = useState(false);
8486
const beep = useRef();
8587

8688
const updateLintingMessageAccessibility = debounce((annotations) => {
@@ -95,6 +97,14 @@ function Editor({
9597
}
9698
}, 2000);
9799

100+
const showSearch = useCallback(() => {
101+
setIsSearchOpen(true);
102+
}, []);
103+
104+
const hideSearch = useCallback(() => {
105+
setIsSearchOpen(false);
106+
}, []);
107+
98108
// The useCodeMirror hook manages CodeMirror state and returns
99109
// a reference to the actual CM instance.
100110
const {
@@ -103,7 +113,6 @@ function Editor({
103113
codemirrorView,
104114
getContent,
105115
tidyCode,
106-
showSearch,
107116
updateEditorFileContent
108117
} = useCodeMirror({
109118
project,
@@ -121,6 +130,7 @@ function Editor({
121130
fontSize,
122131
updateLintingMessageAccessibility,
123132
setCurrentLine,
133+
showSearch,
124134
referenceBaseUrl: getReferenceBaseUrl(htmlFile),
125135
p5Version: versionInfo?.version
126136
});
@@ -218,6 +228,12 @@ function Editor({
218228
</div>
219229
</div>
220230
<article ref={onContainerMounted} className={editorHolderClass} />
231+
{isSearchOpen && (
232+
<SearchPanel
233+
view={codemirrorView.current}
234+
closePanel={hideSearch}
235+
/>
236+
)}
221237
{file.url ? <AssetPreview url={file.url} name={file.name} /> : null}
222238
<EditorAccessibility
223239
lintMessages={lintMessages}
@@ -243,6 +259,13 @@ function Editor({
243259
currentLine={currentLine}
244260
/>
245261
</section>
262+
{isSearchOpen && (
263+
<SearchPanel
264+
isMobile
265+
view={codemirrorView.current}
266+
closePanel={hideSearch}
267+
/>
268+
)}
246269
</EditorContainer>
247270
)
248271
}

0 commit comments

Comments
 (0)