diff --git a/app/assets/javascripts/components/copy_button.ts b/app/assets/javascripts/components/copy_button.ts new file mode 100644 index 0000000000..8d271909d9 --- /dev/null +++ b/app/assets/javascripts/components/copy_button.ts @@ -0,0 +1,70 @@ +import { ShadowlessLitElement } from "components/shadowless_lit_element"; +import { html, PropertyValues, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { initTooltips, ready } from "util.js"; + +/** + * A button that copies the text content of a given element to the clipboard. + * The button is styled as a small icon button. + * The button is a tooltip that shows the current status of the copy operation. + * + * @element d-copy-button + * + * @property {HTMLElement} codeElement - The element whose text content is copied to the clipboard. + */ +@customElement("d-copy-button") +export class CopyButton extends ShadowlessLitElement { + @property({ type: Object }) + codeElement: HTMLElement; + + get code(): string { + return this.codeElement.textContent; + } + + @property({ state: true }) + status: "idle" | "success" | "error" = "idle"; + + async copyCode(): Promise { + try { + await navigator.clipboard.writeText(this.code); + this.status = "success"; + } catch (err) { + window.getSelection().selectAllChildren(this.codeElement); + this.status = "error"; + } + } + + get tooltip(): string { + switch (this.status) { + case "success": + return I18n.t("js.copy-success"); + case "error": + return I18n.t("js.copy-fail"); + default: + return I18n.t("js.code.copy-to-clipboard"); + } + } + + protected updated(_changedProperties: PropertyValues): void { + super.updated(_changedProperties); + initTooltips(this); + } + + constructor() { + super(); + + // Reload when I18n is loaded + ready.then(() => this.requestUpdate()); + } + + protected render(): TemplateResult { + return html``; + } +} diff --git a/app/assets/javascripts/exercise.ts b/app/assets/javascripts/exercise.ts index 0dc1dc2916..daf10d4be8 100644 --- a/app/assets/javascripts/exercise.ts +++ b/app/assets/javascripts/exercise.ts @@ -3,6 +3,8 @@ import { initTooltips, updateURLParameter, fetch } from "util.js"; import { Toast } from "./toast"; import GLightbox from "glightbox"; import { IFrameMessageData } from "iframe-resizer"; +import { render } from "lit"; +import { CopyButton } from "components/copy_button"; function showLightbox(content): void { const lightbox = new GLightbox(content); @@ -110,9 +112,21 @@ function initMathJax(): void { }; } +function initCodeFragments(): void { + const codeElements = document.querySelectorAll("pre code"); + codeElements.forEach((codeElement: HTMLElement) => { + const copyButton = new CopyButton(); + copyButton.codeElement = codeElement; + + render(copyButton, codeElement.parentElement, { renderBefore: codeElement }); + initTooltips(codeElement); + }); +} + function initExerciseDescription(): void { initLightboxes(); centerImagesAndTables(); + initCodeFragments(); } function initExerciseShow(exerciseId: number, programmingLanguage: string, loggedIn: boolean, editorShown: boolean, courseId: number, _deadline: string, baseSubmissionsUrl: string): void { diff --git a/app/assets/stylesheets/components/btn.css.scss b/app/assets/stylesheets/components/btn.css.scss index 132f58e027..821b0aa102 100644 --- a/app/assets/stylesheets/components/btn.css.scss +++ b/app/assets/stylesheets/components/btn.css.scss @@ -138,7 +138,7 @@ cursor: default; } - &:not(.btn-icon-filled) &:not(.disabled-with-tooltip) { + &:not(.btn-icon-filled, .disabled-with-tooltip) { &:hover, &:focus, &:active { diff --git a/app/assets/stylesheets/models/activities.css.scss b/app/assets/stylesheets/models/activities.css.scss index 30e2118a2c..f0c7fd07f4 100644 --- a/app/assets/stylesheets/models/activities.css.scss +++ b/app/assets/stylesheets/models/activities.css.scss @@ -185,3 +185,14 @@ center img { padding-top: 0; } } + +pre { + position: relative; + + d-copy-button { + position: absolute; + right: 0; + top: 0; + background-color: $code-bg; + } +} diff --git a/app/helpers/activity_helper.rb b/app/helpers/activity_helper.rb index b008c4f605..acd9a84e18 100644 --- a/app/helpers/activity_helper.rb +++ b/app/helpers/activity_helper.rb @@ -80,7 +80,7 @@ def description_iframe(activity) class: 'dodona-iframe', scrolling: 'no', onload: resizeframe, - allow: 'fullscreen https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com/ ', + allow: 'clipboard-write; fullscreen https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com/ ', src: url, height: '500px' end diff --git a/app/javascript/packs/description.js b/app/javascript/packs/description.js index b2aac00cdc..f4e3c3326d 100644 --- a/app/javascript/packs/description.js +++ b/app/javascript/packs/description.js @@ -1,6 +1,9 @@ import { iframeResizerContentWindow } from "iframe-resizer"; import { initExerciseDescription, initMathJax } from "exercise.ts"; +import { I18n } from "i18n/i18n"; +window.I18n = new I18n(); + window.iframeResizerContentWindow = iframeResizerContentWindow; window.dodona.initMathJax = initMathJax; window.dodona.initDescription = initExerciseDescription; diff --git a/app/views/activities/description.html.erb b/app/views/activities/description.html.erb index 0718150975..08e4171050 100644 --- a/app/views/activities/description.html.erb +++ b/app/views/activities/description.html.erb @@ -31,5 +31,7 @@