@difizen/libro-search-code-cell 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,377 @@
1
+ import type { LibroCodeCellView } from '@difizen/libro-code-cell';
2
+ import type { IPosition, SearchMatch } from '@difizen/libro-code-editor';
3
+ import type { BaseSearchProvider, SearchFilters } from '@difizen/libro-search';
4
+ import { searchText } from '@difizen/libro-search';
5
+ import type { Event } from '@difizen/mana-app';
6
+ import { prop } from '@difizen/mana-app';
7
+ import { DisposableCollection, Emitter } from '@difizen/mana-app';
8
+ import { watch } from '@difizen/mana-app';
9
+ import { inject, transient } from '@difizen/mana-app';
10
+
11
+ import type { CodeEditorSearchHighlighter } from './code-cell-search-protocol.js';
12
+ import { CodeEditorSearchHighlighterFactory } from './code-cell-search-protocol.js';
13
+ /**
14
+ * Search provider for cells.
15
+ */
16
+ @transient()
17
+ export class CodeEditorCellSearchProvider implements BaseSearchProvider {
18
+ protected toDispose = new DisposableCollection();
19
+ /**
20
+ * CodeMirror search highlighter
21
+ */
22
+ @prop() protected editorHighlighter: CodeEditorSearchHighlighter;
23
+ /**
24
+ * Current match index
25
+ */
26
+ @prop() protected currentIndex: number | undefined = undefined;
27
+ /**
28
+ * Current search filters
29
+ */
30
+ @prop() protected filters: SearchFilters | undefined;
31
+ /**
32
+ * Current search query
33
+ */
34
+ protected query: RegExp | null = null;
35
+ // Needs to be protected so subclass can emit the signal too.
36
+ protected stateChangedEmitter: Emitter<void>;
37
+ protected _isActive = true;
38
+ protected _isDisposed = false;
39
+ protected lastReplacementPosition: IPosition | null = null;
40
+ protected highlighterFactory: CodeEditorSearchHighlighterFactory;
41
+ protected cell: LibroCodeCellView;
42
+ /**
43
+ * Constructor
44
+ *
45
+ * @param cell Cell widget
46
+ */
47
+ constructor(
48
+ @inject(CodeEditorSearchHighlighterFactory)
49
+ highlighterFactory: CodeEditorSearchHighlighterFactory,
50
+ cell: LibroCodeCellView,
51
+ ) {
52
+ this.cell = cell;
53
+ this.highlighterFactory = highlighterFactory;
54
+ this.currentIndex = undefined;
55
+ this.stateChangedEmitter = new Emitter<void>();
56
+ this.toDispose.push(this.stateChangedEmitter);
57
+ this.editorHighlighter = this.highlighterFactory(this.cell.editor);
58
+
59
+ this.toDispose.push(watch(this.cell.model, 'value', this.updateMatches));
60
+ this.toDispose.push(
61
+ watch(this.cell, 'editor', async () => {
62
+ await this.cell.editorReady;
63
+ if (this.cell.hasInputHidden === true) {
64
+ this.endQuery();
65
+ } else {
66
+ this.startQuery(this.query, this.filters);
67
+ }
68
+ }),
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Get an initial query value if applicable so that it can be entered
74
+ * into the search box as an initial query
75
+ *
76
+ * @returns Initial value used to populate the search box.
77
+ */
78
+ getInitialQuery(): string {
79
+ const selection = this.cell?.editor?.getSelectionValue();
80
+ // if there are newlines, just return empty string
81
+ return selection?.search(/\r?\n|\r/g) === -1 ? selection : '';
82
+ }
83
+
84
+ get disposed() {
85
+ return this._isDisposed;
86
+ }
87
+
88
+ protected async setEditor() {
89
+ await this.cell.editorReady;
90
+ if (this.cell.editor) {
91
+ this.editorHighlighter.setEditor(this.cell.editor);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Changed signal to be emitted when search matches change.
97
+ */
98
+ get stateChanged(): Event<void> {
99
+ return this.stateChangedEmitter.event;
100
+ }
101
+
102
+ /**
103
+ * Current match index
104
+ */
105
+ get currentMatchIndex(): number | undefined {
106
+ return this.isActive ? this.currentIndex : undefined;
107
+ }
108
+
109
+ /**
110
+ * Whether the cell search is active.
111
+ *
112
+ * This is used when applying search only on selected cells.
113
+ */
114
+ get isActive(): boolean {
115
+ return this._isActive;
116
+ }
117
+
118
+ /**
119
+ * Whether the search provider is disposed or not.
120
+ */
121
+ get isDisposed(): boolean {
122
+ return this._isDisposed;
123
+ }
124
+
125
+ /**
126
+ * Number of matches in the cell.
127
+ */
128
+ get matchesCount(): number {
129
+ return this.isActive ? this.editorHighlighter.matches.length : 0;
130
+ }
131
+
132
+ get isCellSelected(): boolean {
133
+ return this.cell.parent.isSelected(this.cell);
134
+ }
135
+
136
+ /**
137
+ * Clear currently highlighted match
138
+ */
139
+ clearHighlight(): Promise<void> {
140
+ this.currentIndex = undefined;
141
+ this.editorHighlighter.clearHighlight();
142
+
143
+ return Promise.resolve();
144
+ }
145
+
146
+ /**
147
+ * Dispose the search provider
148
+ */
149
+ dispose(): void {
150
+ if (this.isDisposed) {
151
+ return;
152
+ }
153
+ this.toDispose.dispose();
154
+ this._isDisposed = true;
155
+ if (this.isActive) {
156
+ this.endQuery().catch((reason) => {
157
+ console.error(`Failed to end search query on cells.`, reason);
158
+ });
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Set `isActive` status.
164
+ *
165
+ * #### Notes
166
+ * It will start or end the search
167
+ *
168
+ * @param v New value
169
+ */
170
+ async setIsActive(v: boolean): Promise<void> {
171
+ if (this.isActive !== v) {
172
+ this._isActive = v;
173
+ }
174
+ if (this.isActive) {
175
+ if (this.query !== null) {
176
+ await this.startQuery(this.query, this.filters);
177
+ }
178
+ } else {
179
+ await this.endQuery();
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Initialize the search using the provided options. Should update the UI
185
+ * to highlight all matches and "select" the first match.
186
+ *
187
+ * @param query A RegExp to be use to perform the search
188
+ * @param filters Filter parameters to pass to provider
189
+ */
190
+ async startQuery(query: RegExp | null, filters?: SearchFilters): Promise<void> {
191
+ this.query = query;
192
+ this.filters = filters;
193
+ if (this.cell.hasInputHidden) {
194
+ return;
195
+ }
196
+ await this.setEditor();
197
+ // Search input
198
+ await this.updateMatches();
199
+ }
200
+
201
+ /**
202
+ * Stop the search and clean any UI elements.
203
+ */
204
+ async endQuery(): Promise<void> {
205
+ this.currentIndex = undefined;
206
+ this.query = null;
207
+ await this.editorHighlighter.endQuery();
208
+ }
209
+
210
+ /**
211
+ * Highlight the next match.
212
+ *
213
+ * @returns The next match if there is one.
214
+ */
215
+ async highlightNext(): Promise<SearchMatch | undefined> {
216
+ if (this.matchesCount === 0 || !this.isActive) {
217
+ this.currentIndex = undefined;
218
+ } else {
219
+ if (this.lastReplacementPosition) {
220
+ this.cell.editor?.setCursorPosition(this.lastReplacementPosition);
221
+ this.lastReplacementPosition = null;
222
+ }
223
+
224
+ // This starts from the cursor position
225
+ const match = await this.editorHighlighter.highlightNext();
226
+
227
+ if (match) {
228
+ this.currentIndex = this.editorHighlighter.currentIndex;
229
+ } else {
230
+ this.currentIndex = undefined;
231
+ }
232
+ return match;
233
+ }
234
+
235
+ return Promise.resolve(this.getCurrentMatch());
236
+ }
237
+
238
+ /**
239
+ * Highlight the previous match.
240
+ *
241
+ * @returns The previous match if there is one.
242
+ */
243
+ async highlightPrevious(): Promise<SearchMatch | undefined> {
244
+ if (this.matchesCount === 0 || !this.isActive) {
245
+ this.currentIndex = undefined;
246
+ } else {
247
+ // This starts from the cursor position
248
+ const match = await this.editorHighlighter.highlightPrevious();
249
+ if (match) {
250
+ this.currentIndex = this.editorHighlighter.currentIndex;
251
+ } else {
252
+ this.currentIndex = undefined;
253
+ }
254
+ return match;
255
+ }
256
+
257
+ return Promise.resolve(this.getCurrentMatch());
258
+ }
259
+
260
+ /**
261
+ * Replace the currently selected match with the provided text.
262
+ *
263
+ * If no match is selected, it won't do anything.
264
+ *
265
+ * @param newText The replacement text.
266
+ * @returns Whether a replace occurred.
267
+ */
268
+ replaceCurrentMatch(newText: string): Promise<boolean> {
269
+ if (!this.isActive) {
270
+ return Promise.resolve(false);
271
+ }
272
+
273
+ let occurred = false;
274
+
275
+ if (
276
+ this.currentIndex !== undefined &&
277
+ this.currentIndex < this.editorHighlighter.matches.length
278
+ ) {
279
+ const editor = this.cell.editor;
280
+ const selection = editor?.getSelectionValue();
281
+ const match = this.getCurrentMatch();
282
+ if (!match) {
283
+ return Promise.resolve(occurred);
284
+ }
285
+ // If cursor is not on a selection, highlight the next match
286
+ if (selection !== match?.text) {
287
+ this.currentIndex = undefined;
288
+ // The next will be highlighted as a consequence of this returning false
289
+ } else {
290
+ this.editorHighlighter.matches.splice(this.currentIndex, 1);
291
+ this.currentIndex = undefined;
292
+ // Store the current position to highlight properly the next search hit
293
+ this.lastReplacementPosition = editor?.getCursorPosition() ?? null;
294
+ editor?.replaceSelection(newText, {
295
+ start: editor.getPositionAt(match.position)!,
296
+ end: editor.getPositionAt(match.position + match.text.length)!,
297
+ });
298
+ occurred = true;
299
+ }
300
+ }
301
+ return Promise.resolve(occurred);
302
+ }
303
+
304
+ /**
305
+ * Replace all matches in the cell source with the provided text
306
+ *
307
+ * @param newText The replacement text.
308
+ * @returns Whether a replace occurred.
309
+ */
310
+ replaceAllMatches = (newText: string): Promise<boolean> => {
311
+ if (!this.isActive) {
312
+ return Promise.resolve(false);
313
+ }
314
+
315
+ const occurred = this.editorHighlighter.matches.length > 0;
316
+ // const src = this.cell.model.value;
317
+ // let lastEnd = 0;
318
+ // const finalSrc = this.cmHandler.matches.reduce((agg, match) => {
319
+ // const start = match.position as number;
320
+ // const end = start + match.text.length;
321
+ // const newStep = `${agg}${src.slice(lastEnd, start)}${newText}`;
322
+ // lastEnd = end;
323
+ // return newStep;
324
+ // }, '');
325
+
326
+ const editor = this.cell.editor;
327
+ if (occurred && editor) {
328
+ const changes = this.editorHighlighter.matches.map((match) => ({
329
+ range: {
330
+ start: editor.getPositionAt(match.position)!,
331
+ end: editor.getPositionAt(match.position + match.text.length)!,
332
+ },
333
+ text: newText,
334
+ }));
335
+ editor?.replaceSelections(changes);
336
+ this.editorHighlighter.matches = [];
337
+ this.currentIndex = undefined;
338
+ // this.cell.model.setSource(`${finalSrc}${src.slice(lastEnd)}`);
339
+ }
340
+ return Promise.resolve(occurred);
341
+ };
342
+
343
+ /**
344
+ * Get the current match if it exists.
345
+ *
346
+ * @returns The current match
347
+ */
348
+ protected getCurrentMatch(): SearchMatch | undefined {
349
+ if (this.currentIndex === undefined) {
350
+ return undefined;
351
+ } else {
352
+ let match: SearchMatch | undefined = undefined;
353
+ if (this.currentIndex < this.editorHighlighter.matches.length) {
354
+ match = this.editorHighlighter.matches[this.currentIndex];
355
+ }
356
+ return match;
357
+ }
358
+ }
359
+
360
+ protected updateMatches = async () => {
361
+ if (this.query !== null) {
362
+ if (this.isActive) {
363
+ const matches = await searchText(this.query, this.cell.model.value);
364
+ this.editorHighlighter.matches = matches;
365
+ if (this.isCellSelected) {
366
+ const cursorOffset = this.cell.editor!.getOffsetAt(
367
+ this.cell.editor?.getCursorPosition() ?? { column: 0, line: 0 },
368
+ );
369
+ const index = matches.findIndex((item) => item.position >= cursorOffset);
370
+ this.currentIndex = index;
371
+ }
372
+ } else {
373
+ this.editorHighlighter.matches = [];
374
+ }
375
+ }
376
+ };
377
+ }
@@ -0,0 +1,10 @@
1
+ import 'reflect-metadata';
2
+ import assert from 'assert';
3
+
4
+ import { SearchCodeCellModule } from './index.js';
5
+
6
+ describe('libro-search-code-cell', () => {
7
+ it('#import', () => {
8
+ assert(SearchCodeCellModule);
9
+ });
10
+ });
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './code-cell-search-protocol.js';
2
+ export * from './code-cell-search-provider.js';
3
+ export * from './code-editor-cell-search-provider.js';
4
+ export * from './module.js';
package/src/module.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type { IEditor } from '@difizen/libro-code-editor';
2
+ import { LibroSearchModule } from '@difizen/libro-search';
3
+ import { ManaModule } from '@difizen/mana-app';
4
+
5
+ import {
6
+ CodeCellSearchOption,
7
+ CodeCellSearchProviderFactory,
8
+ CodeEditorSearchHighlighterFactory,
9
+ } from './code-cell-search-protocol.js';
10
+ import { CodeCellSearchProviderContribution } from './code-cell-search-provider-contribution.js';
11
+ import { CodeCellSearchProvider } from './code-cell-search-provider.js';
12
+ import { GenericSearchHighlighter } from './search-highlighter.js';
13
+
14
+ export const SearchCodeCellModule = ManaModule.create()
15
+ .register(
16
+ CodeCellSearchProvider,
17
+ CodeCellSearchProviderContribution,
18
+ CodeCellSearchProviderFactory,
19
+ GenericSearchHighlighter,
20
+ {
21
+ token: CodeEditorSearchHighlighterFactory,
22
+ useFactory: (ctx) => {
23
+ return (editor: IEditor) => {
24
+ const child = ctx.container.createChild();
25
+ const highlighter = child.get(GenericSearchHighlighter);
26
+ highlighter.setEditor(editor);
27
+ return highlighter;
28
+ };
29
+ },
30
+ },
31
+ {
32
+ token: CodeCellSearchProviderFactory,
33
+ useFactory: (ctx) => {
34
+ return (options: CodeCellSearchOption) => {
35
+ const child = ctx.container.createChild();
36
+ child.register({
37
+ token: CodeCellSearchOption,
38
+ useValue: options,
39
+ });
40
+ const model = child.get(CodeCellSearchProvider);
41
+ return model;
42
+ };
43
+ },
44
+ },
45
+ )
46
+ .dependOn(LibroSearchModule);
@@ -0,0 +1,207 @@
1
+ /* eslint-disable no-param-reassign */
2
+ import type { IEditor, SearchMatch } from '@difizen/libro-code-editor';
3
+ import { deepEqual } from '@difizen/libro-common';
4
+ import { LibroSearchUtils } from '@difizen/libro-search';
5
+ import { prop } from '@difizen/mana-app';
6
+ import { inject, transient } from '@difizen/mana-app';
7
+
8
+ import type { CodeEditorSearchHighlighter } from './code-cell-search-protocol.js';
9
+
10
+ /**
11
+ * Helper class to highlight texts in a code mirror editor.
12
+ *
13
+ * Highlighted texts (aka `matches`) must be provided through
14
+ * the `matches` attributes.
15
+ */
16
+ @transient()
17
+ export class GenericSearchHighlighter implements CodeEditorSearchHighlighter {
18
+ @inject(LibroSearchUtils) utils: LibroSearchUtils;
19
+
20
+ protected editor: IEditor | undefined;
21
+ @prop() _currentIndex: number | undefined;
22
+ @prop() protected _matches: SearchMatch[];
23
+
24
+ /**
25
+ * The list of matches
26
+ */
27
+ get matches(): SearchMatch[] {
28
+ return this._matches;
29
+ }
30
+ set matches(v: SearchMatch[]) {
31
+ if (!deepEqual(this._matches as any, v as any)) {
32
+ this._matches = v;
33
+ }
34
+ this.refresh();
35
+ }
36
+
37
+ get currentIndex(): number | undefined {
38
+ return this._currentIndex;
39
+ }
40
+ set currentIndex(v: number | undefined) {
41
+ this._currentIndex = v;
42
+ this.refresh();
43
+ }
44
+
45
+ /**
46
+ * Constructor
47
+ *
48
+ * @param editor The CodeMirror editor
49
+ */
50
+ constructor() {
51
+ this._matches = new Array<SearchMatch>();
52
+ this.currentIndex = undefined;
53
+ }
54
+
55
+ /**
56
+ * Clear all highlighted matches
57
+ */
58
+ clearHighlight(): void {
59
+ this.currentIndex = undefined;
60
+ this._highlightCurrentMatch();
61
+ }
62
+
63
+ /**
64
+ * Clear the highlighted matches.
65
+ */
66
+ endQuery(): Promise<void> {
67
+ this._currentIndex = undefined;
68
+ this._matches = [];
69
+
70
+ if (this.editor) {
71
+ this.editor.highlightMatches([], undefined);
72
+
73
+ const selection = this.editor.getSelection();
74
+
75
+ const start = this.editor.getOffsetAt(selection.start);
76
+ const end = this.editor.getOffsetAt(selection.end);
77
+
78
+ // Setting a reverse selection to allow search-as-you-type to maintain the
79
+ // current selected match. See comment in _findNext for more details.
80
+ if (start !== end) {
81
+ this.editor.setSelection(selection);
82
+ }
83
+ }
84
+
85
+ return Promise.resolve();
86
+ }
87
+
88
+ /**
89
+ * Highlight the next match
90
+ *
91
+ * @returns The next match if available
92
+ */
93
+ highlightNext(): Promise<SearchMatch | undefined> {
94
+ this.currentIndex = this._findNext(false);
95
+ this._highlightCurrentMatch();
96
+ return Promise.resolve(
97
+ this.currentIndex !== undefined ? this._matches[this.currentIndex] : undefined,
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Highlight the previous match
103
+ *
104
+ * @returns The previous match if available
105
+ */
106
+ highlightPrevious(): Promise<SearchMatch | undefined> {
107
+ this.currentIndex = this._findNext(true);
108
+ this._highlightCurrentMatch();
109
+ return Promise.resolve(
110
+ this.currentIndex !== undefined ? this._matches[this.currentIndex] : undefined,
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Set the editor
116
+ *
117
+ * @param editor Editor
118
+ */
119
+ setEditor(editor: IEditor): void {
120
+ this.editor = editor;
121
+ this.refresh();
122
+ if (this.currentIndex !== undefined) {
123
+ this._highlightCurrentMatch();
124
+ }
125
+ }
126
+
127
+ protected _highlightCurrentMatch(): void {
128
+ if (!this.editor) {
129
+ // no-op
130
+ return;
131
+ }
132
+
133
+ // Highlight the current index
134
+ if (this.currentIndex !== undefined) {
135
+ const match = this.matches[this.currentIndex];
136
+ // this.cm.editor.focus();
137
+ const start = this.editor.getPositionAt(match.position);
138
+ const end = this.editor.getPositionAt(match.position + match.text.length);
139
+ if (start && end) {
140
+ this.editor.setSelection({ start, end });
141
+ this.editor.revealSelection({ start, end });
142
+ }
143
+ } else {
144
+ const start = this.editor.getPositionAt(0)!;
145
+ const end = this.editor.getPositionAt(0)!;
146
+ // Set cursor to remove any selection
147
+ this.editor.setSelection({ start, end });
148
+ }
149
+ }
150
+
151
+ protected refresh(): void {
152
+ if (!this.editor) {
153
+ // no-op
154
+ return;
155
+ }
156
+ this.editor.highlightMatches(this.matches, this.currentIndex);
157
+ }
158
+
159
+ protected _findNext(reverse: boolean): number | undefined {
160
+ if (this.matches.length === 0) {
161
+ // No-op
162
+ return undefined;
163
+ }
164
+ if (!this.editor) {
165
+ return;
166
+ }
167
+ // In order to support search-as-you-type, we needed a way to allow the first
168
+ // match to be selected when a search is started, but prevent the selected
169
+ // search to move for each new keypress. To do this, when a search is ended,
170
+ // the cursor is reversed, putting the head at the 'from' position. When a new
171
+ // search is started, the cursor we want is at the 'from' position, so that the same
172
+ // match is selected when the next key is entered (if it is still a match).
173
+ //
174
+ // When toggling through a search normally, the cursor is always set in the forward
175
+ // direction, so head is always at the 'to' position. That way, if reverse = false,
176
+ // the search proceeds from the 'to' position during normal toggling. If reverse = true,
177
+ // the search always proceeds from the 'anchor' position, which is at the 'from'.
178
+
179
+ const selection = this.editor?.getSelection();
180
+
181
+ const start = this.editor?.getOffsetAt(selection.start);
182
+ const end = this.editor.getOffsetAt(selection.end);
183
+ let lastPosition = reverse ? start : end;
184
+ if (lastPosition === 0 && reverse && this.currentIndex === undefined) {
185
+ // The default position is (0, 0) but we want to start from the end in that case
186
+ lastPosition = this.editor.model.value.length;
187
+ }
188
+
189
+ const position = lastPosition;
190
+
191
+ let found = this.utils.findNext(this.matches, position, 0, this.matches.length - 1);
192
+ if (found === undefined) {
193
+ // Don't loop
194
+ return reverse ? this.matches.length - 1 : undefined;
195
+ }
196
+
197
+ if (reverse) {
198
+ found -= 1;
199
+ if (found < 0) {
200
+ // Don't loop
201
+ return undefined;
202
+ }
203
+ }
204
+
205
+ return found;
206
+ }
207
+ }