Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions client/modules/IDE/components/Editor/p5CompletionPreview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { StateField, RangeSetBuilder } from '@codemirror/state';
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { selectedCompletion, completionStatus } from '@codemirror/autocomplete';

class GhostTextWidget extends WidgetType {
constructor(text) {
super();
this.text = text;
}

eq(other) {
return other.text === this.text;
}

toDOM() {
const span = document.createElement('span');
span.className = 'cm-ghostCompletion';
span.textContent = this.text;
return span;
}

ignoreEvent() {
return true;
}
}

function getCurrentWord(state) {
const { from, to, empty } = state.selection.main;
if (!empty) return null;

const line = state.doc.lineAt(from);
const before = line.text.slice(0, from - line.from);
const match = before.match(/\w+$/);

if (!match) return null;

const word = match[0];
return {
text: word,
from: from - word.length,
to
};
}

function buildGhostText(state) {
// only show ghost text if autocomplete is on,
// user is typing, and if preview matches typed text

if (completionStatus(state) !== 'active') return null;

const selected = selectedCompletion(state);
if (!selected) return null;

const word = getCurrentWord(state);
if (!word) return null;

const preview = selected.preview || selected.label;
if (!preview) return null;

if (!preview.toLowerCase().startsWith(word.text.toLowerCase())) return null;

const remainder = preview.slice(word.text.length);
if (!remainder) return null;

return {
pos: word.to,
text: remainder
};
}

const ghostTextField = StateField.define({
create(state) {
return Decoration.none;
},

update(deco, tr) {
const decorationBuilder = new RangeSetBuilder();
const ghost = buildGhostText(tr.state);

if (ghost) {
decorationBuilder.add(
ghost.pos,
ghost.pos,
Decoration.widget({
widget: new GhostTextWidget(ghost.text),
side: 1
})
);
}

return decorationBuilder.finish();
},

provide: (field) => EditorView.decorations.from(field)
});

export const p5CompletionPreviewTheme = EditorView.theme({
'.cm-ghostCompletion': {
opacity: '0.55',
fontStyle: 'italic',
pointerEvents: 'none',
whiteSpace: 'pre'
}
});

export function p5CompletionPreview() {
return [ghostTextField, p5CompletionPreviewTheme];
}
48 changes: 11 additions & 37 deletions client/modules/IDE/components/Editor/p5JavaScript.js
Original file line number Diff line number Diff line change
@@ -1,54 +1,28 @@
import { LanguageSupport } from '@codemirror/language';
import { javascript } from '@codemirror/lang-javascript';
import { p5Hinter } from '../../../../utils/p5-hinter';
import { p5CompletionPreview } from './p5CompletionPreview';
import contextAwareHinter from '../../../../utils/contextAwareHinter';

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

function addDomNodeInfo(item) {
const itemCopy = { ...item };

if (item.p5DocPath) {
// TODO: Use the option below to add the p5 link for *all* hints.
// https://codemirror.net/docs/ref/#autocomplete.autocompletion^config.addToOptions
itemCopy.info = () => {
const domNode = document.createElement('a');
domNode.href = `https://p5js.org/reference/p5/${item.p5DocPath}`;
domNode.role = 'link';
domNode.target = '_blank';
domNode.onclick = (event) => event.stopPropagation();
domNode.innerHTML = `
<span class="hint-hidden">open ${item.label} reference</span>
<span aria-hidden="true">&#10132;</span>
`;
return {
dom: domNode,
destroy: () => {
// Cleanup logic if needed
domNode.remove();
}
};
};
}

return itemCopy;
if (!word && !context.explicit) {
return null;
}

const hinterWithDomNodes = p5Hinter.map(addDomNodeInfo);

return {
from: word.from,
options: hinterWithDomNodes
};
return contextAwareHinter(context, {
hints: p5Hinter
});
}

export default function p5JavaScript() {
const jsLang = javascript();
return new LanguageSupport(jsLang.language, [
jsLang.extension,
jsLang.language.data.of({
autocomplete: testCompletions
})
autocomplete: addCompletions
}),
p5CompletionPreview()
]);
}
131 changes: 127 additions & 4 deletions client/modules/IDE/components/Editor/stateUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import {
import {
autocompletion,
closeBrackets,
closeBracketsKeymap
closeBracketsKeymap,
completionStatus,
selectedCompletionIndex
} from '@codemirror/autocomplete';
import {
highlightSelectionMatches,
Expand Down Expand Up @@ -258,15 +260,136 @@ function getFileEmmetConfig(fileName) {
}
}

function focusOnReferenceArrow(view) {
if (completionStatus(view.state) !== 'active') return false;

const selectedIndex = selectedCompletionIndex(view.state);
if (selectedIndex == null || selectedIndex < 0) return false;

const tooltip = view.dom.querySelector('.cm-tooltip-autocomplete');
if (!tooltip) return false;

const options = tooltip.querySelectorAll('li.CodeMirror-hint');
const selectedOption = options[selectedIndex];
if (!selectedOption) return false;

const link = selectedOption.querySelector('.cm-completionRefLink');
if (!link) return false;

link.focus();
link.classList.add('focused-hint-link');

const cleanup = () => {
link.classList.remove('focused-hint-link');
link.removeEventListener('blur', cleanup);
};
link.addEventListener('blur', cleanup);

return true;
}

// Extra custom keymaps.
// TODO: We need to add sublime mappings + other missing extra mappings here.
const extraKeymaps = [{ key: 'Tab', run: insertTab, shift: indentLess }];
const extraKeymaps = [
{ key: 'ArrowRight', run: focusOnReferenceArrow },
{ key: 'Tab', run: insertTab, shift: indentLess }
];
const emmetKeymaps = [{ key: 'Tab', run: expandAbbreviation }];

export const AUTOCOMPLETE_OPTIONS = {
tooltipClass: () => 'CodeMirror-hints',
optionClass: () => 'CodeMirror-hint',
closeOnBlur: false
closeOnBlur: false,
icons: false,

// handle css classes
optionClass(completion) {
let className = 'CodeMirror-hint';

if (completion.type) {
className += ` hint-type-${completion.type}`;
}

if (completion.p5DocPath) {
className += ' has-doc-link';
}

return className;
},

addToOptions: [
{
position: 60,
render(completion) {
const kind = document.createElement('span');
kind.className = 'cm-completionKind';
kind.textContent = completion.kindLabel || completion.type || '';
return kind;
}
},
{
position: 80,
render(completion, state, view) {
if (!completion.p5DocPath) return null;

// TODO: add in reference url version switching
const link = document.createElement('a');
link.className = 'cm-completionRefLink';
link.href = `https://p5js.org/reference/p5/${completion.p5DocPath}`;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.tabIndex = -1;
link.setAttribute('aria-label', `Open ${completion.label} reference`);

link.innerHTML = `
<span class="hint-hidden">open ${completion.label} reference</span>
<span aria-hidden="true">&#10132;</span>
`;

link.addEventListener('mousedown', (event) => {
event.preventDefault();
event.stopPropagation();
});

link.addEventListener('click', (event) => {
event.stopPropagation();
});

link.addEventListener('keydown', (event) => {
if (event.key === 'ArrowLeft' || event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
link.classList.remove('focused-hint-link');
view.focus();
}
});

return link;
}
},
{
position: 100,
render(completion) {
if (!completion.blacklisted) return null;

const warning = document.createElement('div');
warning.className = 'cm-completionWarning';

const icon = document.createElement('span');
icon.className = 'cm-completionWarningIcon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = '⚠️';

const text = document.createElement('span');
text.className = 'cm-completionWarningText';
text.textContent = 'use with caution in this context';

warning.appendChild(icon);
warning.appendChild(text);

return warning;
}
}
]
};

/**
Expand Down
Loading
Loading