diff --git a/client/index.jsx b/client/index.jsx
index 27befe8421..dee30b7ec6 100644
--- a/client/index.jsx
+++ b/client/index.jsx
@@ -43,13 +43,18 @@ const App = () => (
);
-render(
-
\ -${name}\ -, \ -${type}\ -, \ -${ - p5 - ? `\ -open ${name} reference\ -` - : `no reference for ${name}` -}
`; - } - - function getInlineHintSuggestion(focus, tokenLength) { + function getInlineHintSuggestion(cm, focus, token) { + let tokenLength = token.string.length; + if (token.string === '.') { + tokenLength -= 1; + } + const name = focus.item?.text; const suggestionItem = focus.item; + // builds the remainder of the suggestion excluding what user already typed const baseCompletion = `${suggestionItem.text.slice( tokenLength )}`; @@ -310,6 +348,7 @@ ${ ); } + // clears existing inline hint (like the part is suggested) function removeInlineHint(cm) { if (cm.state.inlineHint) { cm.state.inlineHint.clear(); @@ -318,17 +357,13 @@ ${ } function changeInlineHint(cm, focus) { - // Copilot-style inline suggestion for autocomplete feature removeInlineHint(cm); const cursor = cm.getCursor(); const token = cm.getTokenAt(cursor); if (token && focus.item) { - const suggestionHTML = getInlineHintSuggestion( - focus, - token.string.length - ); + const suggestionHTML = getInlineHintSuggestion(cm, focus, token); const widgetElement = document.createElement('span'); widgetElement.className = 'autocomplete-inline-hinter'; @@ -336,11 +371,13 @@ ${ const widget = cm.setBookmark(cursor, { widget: widgetElement }); cm.state.inlineHint = widget; - cm.setCursor(cursor); } } + // defines the autocomplete dropdown ui; renders the suggestions + // completion = the autocomplete context having cm and options + // data = object with the list of suggestions function Widget(completion, data) { this.id = 'cm-complete-' + Math.floor(Math.random(1e6)); this.completion = completion; @@ -365,32 +402,41 @@ ${ changeInlineHint(cm, data.list[this.selectedHint]); var completions = data.list; - for (var i = 0; i < completions.length; ++i) { - var elt = hints.appendChild(ownerDocument.createElement('li')), - cur = completions[i]; - var className = + const cur = completions[i]; + + const elt = ownerDocument.createElement('li'); + elt.className = HINT_ELEMENT_CLASS + - (i != this.selectedHint ? '' : ' ' + ACTIVE_HINT_ELEMENT_CLASS); - if (cur.className != null) className = cur.className + ' ' + className; - elt.className = className; - if (i == this.selectedHint) elt.setAttribute('aria-selected', 'true'); + (i !== this.selectedHint ? '' : ' ' + ACTIVE_HINT_ELEMENT_CLASS) + + (cur.isBlacklisted ? ' blacklisted' : ''); + + if (cur.className != null) + elt.className = cur.className + ' ' + elt.className; + + if (i === this.selectedHint) elt.setAttribute('aria-selected', 'true'); elt.id = this.id + '-' + i; elt.setAttribute('role', 'option'); - if (cur.render) cur.render(elt, data, cur); - else { - const e = ownerDocument.createElement('p'); - const name = getText(cur); + elt.hintId = i; + if (cur.render) { + cur.render(elt, data, cur); + } else { + const name = getText(cur); if (cur.item && cur.item.type) { - cur.displayText = displayHint(name, cur.item.type, cur.item.p5); + cur.displayText = displayHint( + name, + cur.item.type, + cur.item.p5, + cur.isBlacklisted + ); } - elt.appendChild(e); - e.outerHTML = - cur.displayText || `${name}`; + elt.innerHTML = + cur.displayText || `${name}`; } - elt.hintId = i; + + hints.appendChild(elt); } var container = completion.options.container || ownerDocument.body; @@ -545,6 +591,13 @@ ${ }) ); + function getHintElement(container, el) { + while (el && el !== container && el.hintId == null) { + el = el.parentNode; + } + return el; + } + CodeMirror.on(hints, 'dblclick', function (e) { var t = getHintElement(hints, e.target || e.srcElement); if (t && t.hintId != null) { diff --git a/client/modules/IDE/selectors/files.js b/client/modules/IDE/selectors/files.js index 2e2699740e..ab1372563b 100644 --- a/client/modules/IDE/selectors/files.js +++ b/client/modules/IDE/selectors/files.js @@ -1,6 +1,6 @@ import { createSelector } from '@reduxjs/toolkit'; -const selectFiles = (state) => state.files; +export const selectFiles = (state) => state.files; export const selectRootFile = createSelector(selectFiles, (files) => files.find((file) => file.name === 'root') diff --git a/client/storeInstance.js b/client/storeInstance.js new file mode 100644 index 0000000000..bd92360c2e --- /dev/null +++ b/client/storeInstance.js @@ -0,0 +1,6 @@ +import setupStore from './store'; + +const initialState = window.__INITIAL_STATE__; +const store = setupStore(initialState); + +export default store; diff --git a/client/styles/components/_hints.scss b/client/styles/components/_hints.scss index 6c01abb721..7084b460e7 100644 --- a/client/styles/components/_hints.scss +++ b/client/styles/components/_hints.scss @@ -15,7 +15,7 @@ font-size: 100%; font-family: Inconsolata, monospace; - width: 18rem; + width: 20rem; max-height: 20rem; overflow-y: auto; @@ -29,8 +29,74 @@ border-bottom: #{math.div(1, $base-font-size)}rem solid getThemifyVariable('hint-item-border-bottom-color'); } - .hint-name { + .hint-container { + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + width: 100%; + height: 100%; + + // Widen the entire row only if a warning is present + &.has-warning { + width: 100%; // Let it fill the parent .CodeMirror-hint width + max-width: 24rem; + } + } + + .hint-main { + display: flex; + justify-content: space-between; + align-items: center; + flex-grow: 1; + padding: 0 0.5rem; + width: 100%; + height: 100%; + // position: relative; // optional, only if you want absolutely-positioned children + } + + .hint-main a { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; height: 100%; + padding: 0; + // margin-left: auto; + text-align: center; + text-decoration: none; + font-size: 1.2rem; + } + + .hint-name { + font-size: 1.2rem; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .hint-type { + font-size: 1rem; + font-weight: normal; + color: #999; + margin-left: 1rem; + margin-right: 2.5rem; // leaves space for the arrow icon + white-space: nowrap; + flex-shrink: 0; + } + + // Warning box + .blacklist-warning { + background-color: #fff3cd; + border: 1px solid #ffc107; + color: #856404; + padding: 6px 10px; + border-radius: 4px; + font-size: 0.85rem; + margin: 0.25rem 0.5rem 0 0.5rem; + width: calc(100% - 1rem); // Match padding + box-sizing: border-box; } .fun-name, .obj-name { @@ -156,7 +222,7 @@ } a, .no-link-placeholder { - position: absolute; + // position: absolute; top: 0; right: 0; height: 100%; @@ -174,6 +240,11 @@ outline: 0; } } + + .CodeMirror-hint.blacklisted { + height: auto; + min-height: 3.2rem; // enough to show the warning + content + } } // Inline hinter diff --git a/client/utils/ScreenReaderHelper.jsx b/client/utils/ScreenReaderHelper.jsx new file mode 100644 index 0000000000..3e3153f9e4 --- /dev/null +++ b/client/utils/ScreenReaderHelper.jsx @@ -0,0 +1,25 @@ +export default function announceToScreenReader(message, assertive = false) { + const liveRegion = document.getElementById('rename-aria-live'); + if (!liveRegion) return; + + liveRegion.setAttribute('aria-live', 'assertive'); + + liveRegion.textContent = ''; + setTimeout(() => { + liveRegion.textContent = message; + }, 50); +} + +export function ensureAriaLiveRegion() { + if (!document.getElementById('rename-aria-live')) { + const liveRegion = document.createElement('div'); + liveRegion.id = 'rename-aria-live'; + liveRegion.setAttribute('aria-live', 'assertive'); + liveRegion.setAttribute('role', 'status'); + liveRegion.style.position = 'absolute'; + liveRegion.style.left = '-9999px'; + liveRegion.style.height = '1px'; + liveRegion.style.overflow = 'hidden'; + document.body.appendChild(liveRegion); + } +} diff --git a/client/utils/contextAwareHinter.js b/client/utils/contextAwareHinter.js new file mode 100644 index 0000000000..8b4af9e1f7 --- /dev/null +++ b/client/utils/contextAwareHinter.js @@ -0,0 +1,190 @@ +/* eslint-disable */ +import getContext from './getContext'; +import p5CodeAstAnalyzer from './p5CodeAstAnalyzer'; +import classMap from './p5-instance-methods-and-creators.json'; + +const scopeMap = require('./p5-scope-function-access-map.json'); + +function getExpressionBeforeCursor(cm) { + const cursor = cm.getCursor(); + const line = cm.getLine(cursor.line); + const uptoCursor = line.slice(0, cursor.ch); + const match = uptoCursor.match( + /([a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*)\.(?:[a-zA-Z_$][\w$]*)?$/ + ); + return match ? match[1] : null; +} + +export default function contextAwareHinter(cm, options = {}) { + const { + variableToP5ClassMap = {}, + scopeToDeclaredVarsMap = {}, + userDefinedFunctionMetadata = {}, + userDefinedClassMetadata = {} + } = p5CodeAstAnalyzer(cm) || {}; + + const { hinter } = options; + if (!hinter || typeof hinter.search !== 'function') { + return []; + } + + const baseExpression = getExpressionBeforeCursor(cm); + + if (baseExpression) { + const className = variableToP5ClassMap[baseExpression]; + const userClassEntry = Object.values(userDefinedClassMetadata).find( + (cls) => cls.initializer === baseExpression + ); + + let methods = []; + + if (userClassEntry?.methods) { + const { methods: userMethods } = userClassEntry; + methods = userMethods; + } else if (className && classMap[className]?.methods) { + const { methods: classMethods } = classMap[className]; + methods = classMethods; + } else { + return []; + } + + const cursor = cm.getCursor(); + const lineText = cm.getLine(cursor.line); + const dotMatch = lineText + .slice(0, cursor.ch) + .match(/\.([a-zA-Z_$][\w$]*)?$/); + + let from = cursor; + if (dotMatch) { + const fullMatch = dotMatch[0]; + const methodStart = cursor.ch - fullMatch.length + 1; + from = { line: cursor.line, ch: methodStart }; + } else { + from = cursor; + } + + const to = { line: cursor.line, ch: cursor.ch }; + const typed = dotMatch?.[1]?.toLowerCase() || ''; + + const methodHints = methods + .filter((method) => method.toLowerCase().startsWith(typed)) + .map((method) => ({ + item: { + text: method, + type: 'fun', + isMethod: true + }, + displayText: method, + from, + to + })); + + return methodHints; + } + + const { line, ch } = cm.getCursor(); + const { string } = cm.getTokenAt({ line, ch }); + const currentWord = string.trim(); + + const currentContext = getContext(cm); + const allHints = hinter.search(currentWord); + + // const whitelist = scopeMap[currentContext]?.whitelist || []; + const blacklist = scopeMap[currentContext]?.blacklist || []; + + const lowerCurrentWord = currentWord.toLowerCase(); + + function isInScope(varName) { + return Object.entries(scopeToDeclaredVarsMap).some( + ([scope, vars]) => + varName in vars && (scope === 'global' || scope === currentContext) + ); + } + + const allVarNames = Array.from( + new Set( + Object.values(scopeToDeclaredVarsMap) + .map((s) => Object.keys(s)) + .flat() + .filter((name) => typeof name === 'string') + ) + ); + + const varHints = allVarNames + .filter( + (varName) => + varName.toLowerCase().startsWith(lowerCurrentWord) && isInScope(varName) + ) + .map((varName) => { + const isFunc = + scopeToDeclaredVarsMap[currentContext]?.[varName] === 'fun' || + (!scopeToDeclaredVarsMap[currentContext]?.[varName] && + scopeToDeclaredVarsMap['global']?.[varName] === 'fun'); + + const baseItem = isFunc + ? { ...userDefinedFunctionMetadata[varName] } + : { + text: varName, + type: 'var', + params: [], + p5: false + }; + + return { + item: baseItem, + isBlacklisted: blacklist.includes(varName) + }; + }); + + const filteredHints = allHints + .filter( + (h) => + h && + h.item && + typeof h.item.text === 'string' && + h.item.text.toLowerCase().startsWith(lowerCurrentWord) + ) + .map((hint) => { + const name = hint.item?.text || ''; + const isBlacklisted = blacklist.includes(name); + + return { + ...hint, + isBlacklisted + }; + }); + + const combinedHints = [...varHints, ...filteredHints]; + + const typePriority = { + fun: 0, + var: 1, + keyword: 2, + other: 3 + }; + + const sorted = combinedHints.sort((a, b) => { + const nameA = a.item?.text || ''; + const nameB = b.item?.text || ''; + const typeA = a.item?.type || 'other'; + const typeB = b.item?.type || 'other'; + + const isBlacklistedA = a.isBlacklisted ? 1 : 0; + const isBlacklistedB = b.isBlacklisted ? 1 : 0; + + const typeScoreA = typePriority[typeA] ?? typePriority.other; + const typeScoreB = typePriority[typeB] ?? typePriority.other; + + if (isBlacklistedA !== isBlacklistedB) { + return isBlacklistedA - isBlacklistedB; + } + + if (typeScoreA !== typeScoreB) { + return typeScoreA - typeScoreB; + } + + return nameA.localeCompare(nameB); + }); + + return sorted; +} diff --git a/client/utils/device.js b/client/utils/device.js deleted file mode 100644 index 040b16b7d4..0000000000 --- a/client/utils/device.js +++ /dev/null @@ -1 +0,0 @@ -export const isMac = () => navigator.userAgent.toLowerCase().indexOf('mac') !== -1; // eslint-disable-line diff --git a/client/utils/device.ts b/client/utils/device.ts new file mode 100644 index 0000000000..06328be18a --- /dev/null +++ b/client/utils/device.ts @@ -0,0 +1,8 @@ +/** + * Checks if the user's OS is macOS based on the `navigator.userAgent` string. + * This is the preferred method over `navigator.platform`, which is now deprecated: + * - see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/platform + */ +export default function isMac(): boolean { + return navigator?.userAgent?.toLowerCase().includes('mac') ?? false; +} diff --git a/client/utils/getContext.js b/client/utils/getContext.js new file mode 100644 index 0000000000..beddef71f7 --- /dev/null +++ b/client/utils/getContext.js @@ -0,0 +1,44 @@ +const parser = require('@babel/parser'); +const traverse = require('@babel/traverse').default; + +export default function getContext(_cm) { + const code = _cm.getValue(); + const cursor = _cm.getCursor(); + const offset = _cm.indexFromPos(cursor); + + let ast; + try { + ast = parser.parse(code, { + sourceType: 'script', + plugins: ['jsx', 'typescript'] + }); + } catch (e) { + return 'global'; + } + + let context = 'global'; + + traverse(ast, { + Function(path) { + const { node } = path; + if (offset >= node.start && offset <= node.end) { + if (node.id && node.id.name) { + context = node.id.name; + } else { + const parent = path.parentPath.node; + if ( + parent.type === 'VariableDeclarator' && + parent.id.type === 'Identifier' + ) { + context = parent.id.name; + } else { + context = '(anonymous)'; + } + } + path.stop(); + } + } + }); + + return context; +} diff --git a/client/utils/jump-to-def-helper.js b/client/utils/jump-to-def-helper.js new file mode 100644 index 0000000000..9d40cf544b --- /dev/null +++ b/client/utils/jump-to-def-helper.js @@ -0,0 +1,68 @@ +/* eslint-disable */ +import p5CodeAstAnalyzer from './p5CodeAstAnalyzer'; +import * as parser from '@babel/parser'; +import announceToScreenReader from './ScreenReaderHelper'; +const traverse = require('@babel/traverse').default; + +export function getScriptLoadOrder(files) { + const indexHtmlFile = files.find((f) => f.name.endsWith('index.html')); + if (!indexHtmlFile) return []; + + const scriptRegex = /