diff --git a/.eslintignore b/.eslintignore index 6fae180148..2f3ce590c5 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,4 @@ app/assets/config/manifest.js -app/assets/javascripts/ace_editor.js app/assets/javascripts/i18n/translations.js app/assets/javascripts/types/index.d.ts app/assets/javascripts/inputServiceWorker.js diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5decbc7a8e..c9a42a0c97 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -37,6 +37,10 @@ updates: rails: patterns: - "@rails*" + codemirror: + patterns: + - "*codemirror*" + - "@lezer*" - package-ecosystem: github-actions directory: "/" schedule: diff --git a/Gemfile b/Gemfile index 0126c5082b..852a9b78f5 100644 --- a/Gemfile +++ b/Gemfile @@ -59,9 +59,6 @@ gem 'builder', '~>3.2.4' # generate diffs gem 'diff-lcs', '~>1.5' -# code editor -gem 'ace-rails-ap', '~>4.5' - # auto css prefixer gem 'autoprefixer-rails', '~>10.4.15' diff --git a/Gemfile.lock b/Gemfile.lock index fc38b0584f..5aa24a0929 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,6 @@ GEM remote: https://rubygems.org/ specs: - ace-rails-ap (4.5) actioncable (7.0.8) actionpack (= 7.0.8) activesupport (= 7.0.8) @@ -523,7 +522,6 @@ PLATFORMS x86_64-linux DEPENDENCIES - ace-rails-ap (~> 4.5) after_commit_everywhere (~> 1.3.1) annotate (~> 3.2.0) autoprefixer-rails (~> 10.4.15) diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index 38695908d1..9a99757a42 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -1,3 +1,2 @@ //= link_tree ../images -//= link ace_editor.js //= link_tree ../builds diff --git a/app/assets/javascripts/ace_editor.js b/app/assets/javascripts/ace_editor.js deleted file mode 100644 index 533c1f59f0..0000000000 --- a/app/assets/javascripts/ace_editor.js +++ /dev/null @@ -1,11 +0,0 @@ -// This is legacy code that is not compiled by webpack but fully depended on the sprockets pipeline. -// It is used by the ace editor to provide syntax highlighting and code completion. -// New code should not be added here. -// -// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details -// about supported directives. -// - -//= require ace-rails-ap -//= require ace/ext-language_tools - diff --git a/app/assets/javascripts/coding_scratchpad.ts b/app/assets/javascripts/coding_scratchpad.ts index 0b132ff645..3745d28d84 100644 --- a/app/assets/javascripts/coding_scratchpad.ts +++ b/app/assets/javascripts/coding_scratchpad.ts @@ -2,14 +2,8 @@ import { Papyros } from "@dodona/papyros"; import { InputMode } from "@dodona/papyros"; import { ProgrammingLanguage } from "@dodona/papyros"; import { themeState } from "state/Theme"; - -/** - * Custom interface to not have to add the ace package as dependency - */ -interface Editor { - setValue(v: string): void; - getValue(): string; -} +import { EditorView } from "@codemirror/view"; +import { setCode } from "editor"; /** Identifiers used in HTML for relevant elements */ const CODE_EDITOR_PARENT_ID = "scratchpad-editor-wrapper"; @@ -25,7 +19,7 @@ const SUBMIT_TAB_ID = "activity-handin-link"; function initCodingScratchpad(programmingLanguage: ProgrammingLanguage): void { if (Papyros.supportsProgrammingLanguage(programmingLanguage)) { let papyros: Papyros | undefined = undefined; - let editor: Editor | undefined = undefined; + let editor: EditorView | undefined = undefined; const closeButton = document.getElementById(CLOSE_BUTTON_ID); // To prevent horizontal scrollbar issues, we delay rendering the button // until after the page is loaded @@ -46,14 +40,14 @@ function initCodingScratchpad(programmingLanguage: ProgrammingLanguage): void { }); editor ||= window.dodona.editor; if (editor) { - // Shortcut to copy code to ACE editor + // Shortcut to copy code to editor papyros.addButton( { id: CODE_COPY_BUTTON_ID, buttonText: I18n.t("js.coding_scratchpad.copy_code") }, () => { - editor.setValue(papyros.getCode()); + setCode(editor, papyros.getCode()); closeButton.click(); // Open submit panel if possible document.getElementById(SUBMIT_TAB_ID)?.click(); @@ -88,7 +82,7 @@ function initCodingScratchpad(programmingLanguage: ProgrammingLanguage): void { document.getElementById(OFFCANVAS_ID).addEventListener("shown.bs.offcanvas", () => { editor ||= window.dodona.editor; if (editor) { // Start with code from the editor, if there is any - const editorCode = editor.getValue(); + const editorCode = editor.state.doc.toString(); const currentCode = papyros.getCode(); if (!currentCode || // Papyros empty // Neither code areas are empty, but they differ diff --git a/app/assets/javascripts/editor.ts b/app/assets/javascripts/editor.ts new file mode 100644 index 0000000000..dfcf9df219 --- /dev/null +++ b/app/assets/javascripts/editor.ts @@ -0,0 +1,221 @@ +import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete"; +import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; +import { + bracketMatching, + foldGutter, + foldKeymap, + HighlightStyle, + indentOnInput, LanguageDescription, + syntaxHighlighting +} from "@codemirror/language"; +import { languages } from "@codemirror/language-data"; +import { + drawSelection, + dropCursor, + EditorView, + highlightActiveLine, + highlightActiveLineGutter, + highlightSpecialChars, + keymap, + lineNumbers +} from "@codemirror/view"; +import { tags } from "@lezer/highlight"; +import { Extension } from "@codemirror/state"; + +declare type EditorEventHandler = (event: FocusEvent, view: EditorView) => boolean | void; + + +// A custom theme for CodeMirror that applies the same CSS as Rouge does, +// meaning we can use our existing themes. +const rougeStyle = HighlightStyle.define([ + { tag: tags.comment, class: "c" }, + { tag: tags.lineComment, class: "c" }, + { tag: tags.blockComment, class: "cm" }, + { tag: tags.docComment, class: "cs" }, + { tag: tags.name, class: "n" }, + { tag: tags.variableName, class: "nv" }, + { tag: tags.typeName, class: "kt" }, + { tag: tags.tagName, class: "nt" }, + { tag: tags.propertyName, class: "py" }, + { tag: tags.attributeName, class: "na" }, + { tag: tags.className, class: "nc" }, + { tag: tags.labelName, class: "nl" }, + { tag: tags.namespace, class: "nn" }, + { tag: tags.macroName, class: "n" }, + { tag: tags.literal, class: "l" }, + { tag: tags.string, class: "s" }, + { tag: tags.docString, class: "sd" }, + { tag: tags.character, class: "sc" }, + { tag: tags.attributeValue, class: "g" }, + { tag: tags.number, class: "m" }, + { tag: tags.integer, class: "mi" }, + { tag: tags.float, class: "mf" }, + { tag: tags.bool, class: "l" }, + { tag: tags.regexp, class: "sr" }, + { tag: tags.escape, class: "se" }, + { tag: tags.color, class: "l" }, + { tag: tags.url, class: "l" }, + { tag: tags.keyword, class: "k" }, + { tag: tags.self, class: "k" }, + { tag: tags.null, class: "l" }, + { tag: tags.atom, class: "l" }, + { tag: tags.unit, class: "l" }, + { tag: tags.modifier, class: "g" }, + { tag: tags.operatorKeyword, class: "ow" }, + { tag: tags.controlKeyword, class: "k" }, + { tag: tags.definitionKeyword, class: "kd" }, + { tag: tags.moduleKeyword, class: "kn" }, + { tag: tags.operator, class: "o" }, + { tag: tags.derefOperator, class: "o" }, + { tag: tags.arithmeticOperator, class: "o" }, + { tag: tags.logicOperator, class: "o" }, + { tag: tags.bitwiseOperator, class: "o" }, + { tag: tags.compareOperator, class: "o" }, + { tag: tags.updateOperator, class: "o" }, + { tag: tags.definitionOperator, class: "o" }, + { tag: tags.typeOperator, class: "o" }, + { tag: tags.controlOperator, class: "o" }, + { tag: tags.punctuation, class: "p" }, + { tag: tags.separator, class: "dl" }, + { tag: tags.bracket, class: "p" }, + { tag: tags.angleBracket, class: "p" }, + { tag: tags.squareBracket, class: "p" }, + { tag: tags.paren, class: "p" }, + { tag: tags.brace, class: "p" }, + { tag: tags.content, class: "g" }, + { tag: tags.heading, class: "gh" }, + { tag: tags.heading1, class: "gu" }, + { tag: tags.heading2, class: "gu" }, + { tag: tags.heading3, class: "gu" }, + { tag: tags.heading4, class: "gu" }, + { tag: tags.heading5, class: "gu" }, + { tag: tags.heading6, class: "gu" }, + { tag: tags.contentSeparator, class: "dl" }, + { tag: tags.list, class: "p" }, + { tag: tags.quote, class: "p" }, + { tag: tags.emphasis, class: "ge" }, + { tag: tags.strong, class: "gs" }, + { tag: tags.link, class: "g" }, + { tag: tags.monospace, class: "go" }, + { tag: tags.strikethrough, class: "gst" }, + { tag: tags.inserted, class: "gi" }, + { tag: tags.deleted, class: "gd" }, + { tag: tags.changed, class: "g" }, + { tag: tags.invalid, class: "err" }, + { tag: tags.meta, class: "c" } +]); + +// Basic, built-in extensions. +const editorSetup = (() => [ + lineNumbers(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + foldGutter(), + drawSelection(), + dropCursor(), + indentOnInput(), + bracketMatching(), + closeBrackets(), + highlightActiveLine(), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...historyKeymap, + ...foldKeymap, + ]), + syntaxHighlighting(rougeStyle, { + fallback: true + }) +])(); + + +// The "@codemirror/language-data" does not support community languages, +// so we add support for those ourselves. +const additionalLanguages = [ + LanguageDescription.of({ + name: "R", + alias: ["rlang"], + extensions: ["r"], + load() { + return import("codemirror-lang-r").then(m => m.r()); + } + }), + LanguageDescription.of({ + name: "Prolog", + alias: ["rlang"], + extensions: ["pl", "pro", "p"], + load() { + return import("codemirror-lang-prolog").then(m => m.prolog()); + } + }), + LanguageDescription.of({ + name: "C#", + alias: ["csharp", "cs"], + extensions: ["cs"], + load() { + return import("@replit/codemirror-lang-csharp").then(m => m.csharp()); + } + }), +]; + + +async function loadProgrammingLanguage(language: string): Promise { + const potentialLanguages = additionalLanguages.concat(languages); + const description = LanguageDescription.matchLanguageName(potentialLanguages, language); + if (description) { + await description.load(); + return description.support; + } + console.warn(`${language} is not supported by our editor, falling back to nothing.`); +} + +/** + * Set up the code editor. + * + * @param parent The element to insert the editor into. Existing content will be inserted into the editor. + * @param programmingLanguage The programming language of the editor. Will attempt to load language support. + * @param focusHandler A callback that will be called when the editor receives focus. + */ +export async function configureEditor(parent: Element, programmingLanguage: string, focusHandler: EditorEventHandler): Promise { + const existingCode = parent.textContent; + // Clear the existing code, as we will put it in CodeMirror. + parent.textContent = ""; + const eventHandlers = EditorView.domEventHandlers({ + "focus": focusHandler + }); + const languageSupport = await loadProgrammingLanguage(programmingLanguage); + const languageExtensions = []; + if (languageSupport !== undefined) { + languageExtensions.push(languageSupport); + } + return new EditorView({ + doc: existingCode, + extensions: [ + // Basic editor functionality + editorSetup, + // Listen for focus + eventHandlers, + // Language support + ...languageExtensions + ], + parent: parent + }); +} + + +/** + * Set the content of a code editor. + * + * @param editorView The code editor to set the content in. + * @param code The code to insert. + */ +export function setCode(editorView: EditorView, code: string): void { + editorView.dispatch(editorView.state.update({ + changes: { + from: 0, + to: editorView.state.doc.length, + insert: code, + } + })); +} diff --git a/app/assets/javascripts/exercise.ts b/app/assets/javascripts/exercise.ts index c012dd02c8..81e214ea41 100644 --- a/app/assets/javascripts/exercise.ts +++ b/app/assets/javascripts/exercise.ts @@ -1,4 +1,4 @@ -/* globals ace */ +import { configureEditor, setCode } from "editor"; import { initTooltips, updateURLParameter, fetch } from "utilities"; import { Toast } from "./toast"; import GLightbox from "glightbox"; @@ -6,6 +6,8 @@ import { IFrameMessageData } from "iframe-resizer"; import { submissionState } from "state/Submissions"; import { render } from "lit"; import { CopyButton } from "components/copy_button"; +import { EditorView } from "@codemirror/view"; + function showLightbox(content): void { const lightbox = new GLightbox(content); @@ -136,14 +138,14 @@ function initExerciseDescription(): void { initCodeFragments(); } -function initExerciseShow(exerciseId: number, programmingLanguage: string, loggedIn: boolean, editorShown: boolean, courseId: number, _deadline: string, baseSubmissionsUrl: string, boilerplate: string): void { - let editor: AceAjax.Editor; +async function initExerciseShow(exerciseId: number, programmingLanguage: string, loggedIn: boolean, editorShown: boolean, courseId: number, _deadline: string, baseSubmissionsUrl: string, boilerplate: string): Promise { + let editor: EditorView; let lastSubmission: string; let lastTimeout: number; - function init(): void { + async function init(): Promise { if (editorShown) { - initEditor(); + await initEditor(); initDeadlineTimeout(); enableSubmissionTableLinks(); swapActionButtons(); @@ -154,7 +156,7 @@ function initExerciseShow(exerciseId: number, programmingLanguage: string, logge document.getElementById("editor-process-btn")?.addEventListener("click", () => { if (!loggedIn) return; // test submitted source code - const source = editor.getValue(); + const source = editor.state.doc.toString(); disableSubmitButton(); submitSolution(source) .then(async response => { @@ -168,14 +170,13 @@ function initExerciseShow(exerciseId: number, programmingLanguage: string, logge }); document.getElementById("submission-copy-btn")?.addEventListener("click", () => { - const codeString = submissionState.code; - editor.setValue(codeString, 1); + setCode(editor, submissionState.code); bootstrap.Tab.getInstance(document.getElementById("activity-handin-link")).show(); }); document.getElementById("activity-handin-link")?.addEventListener("shown.bs.tab", () => { // refresh editor after show - editor.resize(true); + editor.requestMeasure(); }); // secure external links @@ -187,19 +188,9 @@ function initExerciseShow(exerciseId: number, programmingLanguage: string, logge window.dodona.feedbackTableLoaded = feedbackTableLoaded; } - function initEditor(): void { - // init editor - editor = ace.edit("editor-text"); - editor.getSession().setMode("ace/mode/" + programmingLanguage); - editor.setOptions({ - showPrintMargin: false, - enableBasicAutocompletion: true, - }); - editor.getSession().setUseWrapMode(true); - editor.$blockScrolling = Infinity; // disable warning + async function initEditor(): Promise { + editor = await configureEditor(document.getElementById("editor-text"), programmingLanguage, enableSubmitButton); editor.focus(); - editor.on("focus", enableSubmitButton); - editor.commands.removeCommand("find"); // disable search box in ACE editor // Make editor available globally window.dodona.editor = editor; } @@ -464,10 +455,8 @@ function initExerciseShow(exerciseId: number, programmingLanguage: string, logge const wrapper = document.createElement("div"); wrapper.innerHTML = boilerplate; const rawBoilerplate = wrapper.textContent || wrapper.innerText || ""; - - editor.setValue(rawBoilerplate); + setCode(editor, rawBoilerplate); editor.focus(); - editor.clearSelection(); restoreWarning.hidden = true; }); } diff --git a/app/assets/stylesheets/base.css.scss b/app/assets/stylesheets/base.css.scss index a6a822661b..24ec52fbe4 100644 --- a/app/assets/stylesheets/base.css.scss +++ b/app/assets/stylesheets/base.css.scss @@ -7,7 +7,7 @@ @import "bootstrap_variable_overrides.css.scss"; @import "mixins.css.scss"; @import "theme/rouge.css.scss"; -@import "theme/ace.css.scss"; +@import "theme/codemirror.css.scss"; // 3. Include remainder of required Bootstrap stylesheets @import "../../../node_modules/bootstrap/scss/variables"; diff --git a/app/assets/stylesheets/models/activities.css.scss b/app/assets/stylesheets/models/activities.css.scss index ee02238432..a43e1ea456 100644 --- a/app/assets/stylesheets/models/activities.css.scss +++ b/app/assets/stylesheets/models/activities.css.scss @@ -129,12 +129,7 @@ center img { } // editor -#editor-window { - padding: 0; -} - -#editor-text { - position: relative; +#editor-text .cm-editor { height: 300px; } diff --git a/app/assets/stylesheets/models/submissions.css.scss b/app/assets/stylesheets/models/submissions.css.scss index 8376c6fa81..654136bad8 100644 --- a/app/assets/stylesheets/models/submissions.css.scss +++ b/app/assets/stylesheets/models/submissions.css.scss @@ -335,16 +335,6 @@ } } -#editor-text .ace-twilight { - background-color: $gray-10; -} - -/* stylelint-disable-next-line selector-class-pattern */ -#editor-result .ace_content { - background-color: var(--d-code-bg); - cursor: default; -} - iframe.file { border: none; } diff --git a/app/assets/stylesheets/theme/ace.css.scss b/app/assets/stylesheets/theme/ace.css.scss deleted file mode 100644 index fa24a591df..0000000000 --- a/app/assets/stylesheets/theme/ace.css.scss +++ /dev/null @@ -1,115 +0,0 @@ -/* stylelint-disable selector-class-pattern */ -.ace-tm { - background-color: var(--d-code-bg); - color: var(--d-on-background); - - .ace_gutter { - background: var(--d-surface); - color: var(--d-on-surface-muted); - } - - .ace_print-margin { - width: 1px; - background: var(--d-divider); - } - - .ace_cursor { - color: var(--d-secondary); - } - - .ace_marker-layer .ace_selection { - background: var(--d-surface-variant); - } - - &.ace_multiselect .ace_selection.ace_start { - box-shadow: none; - } - - .ace_marker-layer .ace_step { - background: var(--d-secondary-container); - } - - .ace_marker-layer .ace_bracket { - background-color: var(--d-secondary-container); - border: none; - } - - .ace_marker-layer .ace_active-line { - background: var(--d-background); - } - - .ace_gutter-active-line { - background-color: var(--d-background); - } - - .ace_marker-layer .ace_selected-word { - background-color: var(--d-secondary-container); - border: none; - } - - .ace_invisible { - color: var(--d-on-surface-muted); - } - - .ace_keyword, - .ace_keyword.ace_operator, - .ace_meta { - color: var(--d-primary); - } - - .ace_constant, - .ace_constant.ace_character, - .ace_constant.ace_character.ace_escape, - .ace_constant.ace_other, - .ace_support.ace_constant, - .ace_constant.ace_numeric, - .ace_constant.ace_language { - color: var(--d-secondary); - } - - .ace_storage.ace_type { - color: var(--d-secondary); - } - - .ace_invalid { - color: var(--d-on-danger-container); - background-color: var(--d-danger-container); - } - - .ace_fold { - background-color: var(--d-warning-container); - border-color: var(--d-warning-container); - } - - .ace_entity.ace_name, - .ace_entity.ace_name.ace_function, - .ace_support.ace_function { - color: var(--d-tertiary); - } - - .ace_variable.ace_parameter { - font-style: italic; - } - - .ace_string { - color: var(--d-teal); - } - - .ace_string.ace_regexp { - color: var(--d-teal); - } - - .ace_comment { - font-style: italic; - color: var(--d-on-surface-muted); - } - - .ace_meta.ace_tag { - color: var(--d-primary); - } - - .ace_attribute-name.ace_xml { - color: var(--d-tertiary); - } -} -/* stylelint-enable selector-class-pattern */ diff --git a/app/assets/stylesheets/theme/codemirror.css.scss b/app/assets/stylesheets/theme/codemirror.css.scss new file mode 100644 index 0000000000..327d22cfab --- /dev/null +++ b/app/assets/stylesheets/theme/codemirror.css.scss @@ -0,0 +1,33 @@ +.cm-editor { + background: var(--d-code-bg); + + .cm-gutters { + background: var(--d-surface); + color: var(--d-on-surface-muted); + border: none; + } + + .cm-content { + color: var(--d-on-background); + } + + /* stylelint-disable selector-class-pattern */ + .cm-activeLine, .cm-activeLineGutter { + // Make the active line indicate transparant, as GitHub does. + // Otherwise, this interferes with the text selection highlight. + background: transparent; + } + + &.cm-focused { + outline: none !important; + } + + .cm-selectionBackground { + background: var(--d-surface-variant) !important; + } + + .cm-cursor { + // Sets the color of the cursor. + border-left: 1.2px solid var(--d-secondary); + } +} diff --git a/app/assets/stylesheets/theme/rouge.css.scss b/app/assets/stylesheets/theme/rouge.css.scss index a8a2b87ce4..209ca09424 100644 --- a/app/assets/stylesheets/theme/rouge.css.scss +++ b/app/assets/stylesheets/theme/rouge.css.scss @@ -92,6 +92,12 @@ text-decoration: underline; } +/* Generic.Strikethrough */ +.highlighter-rouge .gst { + color: var(--d-on-background); + text-decoration: line-through; +} + /* Generic.Error */ .highlighter-rouge .gr { color: var(--d-on-background); @@ -251,7 +257,6 @@ /* Name.Variable */ .highlighter-rouge .nv { color: var(--d-on-background); - font-style: italic; } /* Operator.Word */ diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/app/views/activities/show.html.erb b/app/views/activities/show.html.erb index 855ecacab3..2bf084e147 100644 --- a/app/views/activities/show.html.erb +++ b/app/views/activities/show.html.erb @@ -1,7 +1,4 @@ <% content_for :javascripts do %> - <% if @activity.exercise? %> - <%= javascript_include_tag 'ace_editor' %> - <% end %> <%= javascript_include_tag 'exercise' %> <% if @activity.exercise? %> <%= javascript_include_tag 'submission' %> @@ -123,7 +120,7 @@ end %> <% end %>
-
<%= @code %>
+
<%= @code %>
<%= t ".hand_in_info" %> <% end %> @@ -173,8 +170,8 @@ end %> <%= render partial: 'coding_scratchpad', locals: {activity: @activity} %> <% end %>