Skip to content

Commit b5e6852

Browse files
authored
Merge pull request #4043 from processing/autocomplete-hinter-parity
2 parents 0f8ac70 + d411d2d commit b5e6852

9 files changed

Lines changed: 717 additions & 180 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { StateField, RangeSetBuilder } from '@codemirror/state';
2+
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
3+
import { selectedCompletion, completionStatus } from '@codemirror/autocomplete';
4+
5+
class GhostTextWidget extends WidgetType {
6+
constructor(text) {
7+
super();
8+
this.text = text;
9+
}
10+
11+
eq(other) {
12+
return other.text === this.text;
13+
}
14+
15+
toDOM() {
16+
const span = document.createElement('span');
17+
span.className = 'cm-ghostCompletion';
18+
span.textContent = this.text;
19+
return span;
20+
}
21+
22+
ignoreEvent() {
23+
return true;
24+
}
25+
}
26+
27+
function getCurrentWord(state) {
28+
const { from, to, empty } = state.selection.main;
29+
if (!empty) return null;
30+
31+
const line = state.doc.lineAt(from);
32+
const before = line.text.slice(0, from - line.from);
33+
const match = before.match(/\w+$/);
34+
35+
if (!match) return null;
36+
37+
const word = match[0];
38+
return {
39+
text: word,
40+
from: from - word.length,
41+
to
42+
};
43+
}
44+
45+
function buildGhostText(state) {
46+
// only show ghost text if autocomplete is on,
47+
// user is typing, and if preview matches typed text
48+
49+
if (completionStatus(state) !== 'active') return null;
50+
51+
const selected = selectedCompletion(state);
52+
if (!selected) return null;
53+
54+
const word = getCurrentWord(state);
55+
if (!word) return null;
56+
57+
const preview = selected.preview || selected.label;
58+
if (!preview) return null;
59+
60+
if (!preview.toLowerCase().startsWith(word.text.toLowerCase())) return null;
61+
62+
const remainder = preview.slice(word.text.length);
63+
if (!remainder) return null;
64+
65+
return {
66+
pos: word.to,
67+
text: remainder
68+
};
69+
}
70+
71+
const ghostTextField = StateField.define({
72+
create(state) {
73+
return Decoration.none;
74+
},
75+
76+
update(deco, tr) {
77+
const decorationBuilder = new RangeSetBuilder();
78+
const ghost = buildGhostText(tr.state);
79+
80+
if (ghost) {
81+
decorationBuilder.add(
82+
ghost.pos,
83+
ghost.pos,
84+
Decoration.widget({
85+
widget: new GhostTextWidget(ghost.text),
86+
side: 1
87+
})
88+
);
89+
}
90+
91+
return decorationBuilder.finish();
92+
},
93+
94+
provide: (field) => EditorView.decorations.from(field)
95+
});
96+
97+
export const p5CompletionPreviewTheme = EditorView.theme({
98+
'.cm-ghostCompletion': {
99+
opacity: '0.55',
100+
fontStyle: 'italic',
101+
pointerEvents: 'none',
102+
whiteSpace: 'pre'
103+
}
104+
});
105+
106+
export function p5CompletionPreview() {
107+
return [ghostTextField, p5CompletionPreviewTheme];
108+
}
Lines changed: 11 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,28 @@
11
import { LanguageSupport } from '@codemirror/language';
22
import { javascript } from '@codemirror/lang-javascript';
33
import { p5Hinter } from '../../../../utils/p5-hinter';
4+
import { p5CompletionPreview } from './p5CompletionPreview';
5+
import contextAwareHinter from '../../../../utils/contextAwareHinter';
46

5-
function testCompletions(context) {
7+
function addCompletions(context) {
68
const word = context.matchBefore(/\w*/);
7-
if (word.from === word.to && !context.explicit) return null;
89

9-
function addDomNodeInfo(item) {
10-
const itemCopy = { ...item };
11-
12-
if (item.p5DocPath) {
13-
// TODO: Use the option below to add the p5 link for *all* hints.
14-
// https://codemirror.net/docs/ref/#autocomplete.autocompletion^config.addToOptions
15-
itemCopy.info = () => {
16-
const domNode = document.createElement('a');
17-
domNode.href = `https://p5js.org/reference/p5/${item.p5DocPath}`;
18-
domNode.role = 'link';
19-
domNode.target = '_blank';
20-
domNode.onclick = (event) => event.stopPropagation();
21-
domNode.innerHTML = `
22-
<span class="hint-hidden">open ${item.label} reference</span>
23-
<span aria-hidden="true">&#10132;</span>
24-
`;
25-
return {
26-
dom: domNode,
27-
destroy: () => {
28-
// Cleanup logic if needed
29-
domNode.remove();
30-
}
31-
};
32-
};
33-
}
34-
35-
return itemCopy;
10+
if (!word && !context.explicit) {
11+
return null;
3612
}
3713

38-
const hinterWithDomNodes = p5Hinter.map(addDomNodeInfo);
39-
40-
return {
41-
from: word.from,
42-
options: hinterWithDomNodes
43-
};
14+
return contextAwareHinter(context, {
15+
hints: p5Hinter
16+
});
4417
}
4518

4619
export default function p5JavaScript() {
4720
const jsLang = javascript();
4821
return new LanguageSupport(jsLang.language, [
4922
jsLang.extension,
5023
jsLang.language.data.of({
51-
autocomplete: testCompletions
52-
})
24+
autocomplete: addCompletions
25+
}),
26+
p5CompletionPreview()
5327
]);
5428
}

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

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import {
2222
import {
2323
autocompletion,
2424
closeBrackets,
25-
closeBracketsKeymap
25+
closeBracketsKeymap,
26+
completionStatus,
27+
selectedCompletionIndex
2628
} from '@codemirror/autocomplete';
2729
import {
2830
highlightSelectionMatches,
@@ -258,15 +260,136 @@ function getFileEmmetConfig(fileName) {
258260
}
259261
}
260262

263+
function focusOnReferenceArrow(view) {
264+
if (completionStatus(view.state) !== 'active') return false;
265+
266+
const selectedIndex = selectedCompletionIndex(view.state);
267+
if (selectedIndex == null || selectedIndex < 0) return false;
268+
269+
const tooltip = view.dom.querySelector('.cm-tooltip-autocomplete');
270+
if (!tooltip) return false;
271+
272+
const options = tooltip.querySelectorAll('li.CodeMirror-hint');
273+
const selectedOption = options[selectedIndex];
274+
if (!selectedOption) return false;
275+
276+
const link = selectedOption.querySelector('.cm-completionRefLink');
277+
if (!link) return false;
278+
279+
link.focus();
280+
link.classList.add('focused-hint-link');
281+
282+
const cleanup = () => {
283+
link.classList.remove('focused-hint-link');
284+
link.removeEventListener('blur', cleanup);
285+
};
286+
link.addEventListener('blur', cleanup);
287+
288+
return true;
289+
}
290+
261291
// Extra custom keymaps.
262292
// TODO: We need to add sublime mappings + other missing extra mappings here.
263-
const extraKeymaps = [{ key: 'Tab', run: insertTab, shift: indentLess }];
293+
const extraKeymaps = [
294+
{ key: 'ArrowRight', run: focusOnReferenceArrow },
295+
{ key: 'Tab', run: insertTab, shift: indentLess }
296+
];
264297
const emmetKeymaps = [{ key: 'Tab', run: expandAbbreviation }];
265298

266299
export const AUTOCOMPLETE_OPTIONS = {
267300
tooltipClass: () => 'CodeMirror-hints',
268-
optionClass: () => 'CodeMirror-hint',
269-
closeOnBlur: false
301+
closeOnBlur: false,
302+
icons: false,
303+
304+
// handle css classes
305+
optionClass(completion) {
306+
let className = 'CodeMirror-hint';
307+
308+
if (completion.type) {
309+
className += ` hint-type-${completion.type}`;
310+
}
311+
312+
if (completion.p5DocPath) {
313+
className += ' has-doc-link';
314+
}
315+
316+
return className;
317+
},
318+
319+
addToOptions: [
320+
{
321+
position: 60,
322+
render(completion) {
323+
const kind = document.createElement('span');
324+
kind.className = 'cm-completionKind';
325+
kind.textContent = completion.kindLabel || completion.type || '';
326+
return kind;
327+
}
328+
},
329+
{
330+
position: 80,
331+
render(completion, state, view) {
332+
if (!completion.p5DocPath) return null;
333+
334+
// TODO: add in reference url version switching
335+
const link = document.createElement('a');
336+
link.className = 'cm-completionRefLink';
337+
link.href = `https://p5js.org/reference/p5/${completion.p5DocPath}`;
338+
link.target = '_blank';
339+
link.rel = 'noopener noreferrer';
340+
link.tabIndex = -1;
341+
link.setAttribute('aria-label', `Open ${completion.label} reference`);
342+
343+
link.innerHTML = `
344+
<span class="hint-hidden">open ${completion.label} reference</span>
345+
<span aria-hidden="true">&#10132;</span>
346+
`;
347+
348+
link.addEventListener('mousedown', (event) => {
349+
event.preventDefault();
350+
event.stopPropagation();
351+
});
352+
353+
link.addEventListener('click', (event) => {
354+
event.stopPropagation();
355+
});
356+
357+
link.addEventListener('keydown', (event) => {
358+
if (event.key === 'ArrowLeft' || event.key === 'Escape') {
359+
event.preventDefault();
360+
event.stopPropagation();
361+
link.classList.remove('focused-hint-link');
362+
view.focus();
363+
}
364+
});
365+
366+
return link;
367+
}
368+
},
369+
{
370+
position: 100,
371+
render(completion) {
372+
if (!completion.blacklisted) return null;
373+
374+
const warning = document.createElement('div');
375+
warning.className = 'cm-completionWarning';
376+
377+
const icon = document.createElement('span');
378+
icon.className = 'cm-completionWarningIcon';
379+
icon.setAttribute('aria-hidden', 'true');
380+
icon.textContent = '⚠️';
381+
382+
const text = document.createElement('span');
383+
text.className = 'cm-completionWarningText';
384+
text.textContent = 'use with caution in this context';
385+
386+
warning.appendChild(icon);
387+
warning.appendChild(text);
388+
389+
return warning;
390+
}
391+
}
392+
]
270393
};
271394

272395
/**

0 commit comments

Comments
 (0)