diff options
Diffstat (limited to 'app/assets/javascripts/behaviors')
5 files changed, 275 insertions, 22 deletions
diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js index a6e203ea5a2..6d2a4c245cc 100644 --- a/app/assets/javascripts/behaviors/copy_code.js +++ b/app/assets/javascripts/behaviors/copy_code.js @@ -29,7 +29,8 @@ class CopyCodeButton extends HTMLElement { } function addCodeButton() { - [...document.querySelectorAll('pre.code.js-syntax-highlight')] + [...document.querySelectorAll('pre.code.js-syntax-highlight:not(.content-editor-code-block)')] + .filter((el) => el.getAttribute('lang') !== 'mermaid') .filter((el) => !el.closest('.js-markdown-code')) .forEach((el) => { const copyCodeEl = document.createElement('copy-code'); diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js index de248340738..c3c28aeafc0 100644 --- a/app/assets/javascripts/behaviors/copy_to_clipboard.js +++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js @@ -1,7 +1,13 @@ -import Clipboard from 'clipboard'; +import ClipboardJS from 'clipboard'; import $ from 'jquery'; -import { sprintf, __ } from '~/locale'; -import { fixTitle, add, show, once } from '~/tooltips'; + +import { parseBoolean } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import { fixTitle, add, show, hide, once } from '~/tooltips'; + +const CLIPBOARD_SUCCESS_EVENT = 'clipboard-success'; +const CLIPBOARD_ERROR_EVENT = 'clipboard-error'; +const I18N_ERROR_MESSAGE = __('Copy failed. Please manually copy the value.'); function showTooltip(target, title) { const { title: originalTitle } = target.dataset; @@ -9,20 +15,31 @@ function showTooltip(target, title) { once('hidden', (tooltip) => { if (tooltip.target === target) { target.setAttribute('title', originalTitle); + target.setAttribute('aria-label', originalTitle); fixTitle(target); } }); target.setAttribute('title', title); + target.setAttribute('aria-label', title); fixTitle(target); show(target); - setTimeout(() => target.blur(), 1000); + setTimeout(() => { + hide(target); + }, 1000); } function genericSuccess(e) { - // Clear the selection and blur the trigger so it loses its border + // Clear the selection e.clearSelection(); - showTooltip(e.trigger, __('Copied')); + e.trigger.focus(); + e.trigger.dispatchEvent(new Event(CLIPBOARD_SUCCESS_EVENT)); + + const { clipboardHandleTooltip = true } = e.trigger.dataset; + if (parseBoolean(clipboardHandleTooltip)) { + // Update tooltip + showTooltip(e.trigger, __('Copied')); + } } /** @@ -30,17 +47,16 @@ function genericSuccess(e) { * See http://clipboardjs.com/#browser-support */ function genericError(e) { - let key; - if (/Mac/i.test(navigator.userAgent)) { - key = '⌘'; // Command - } else { - key = 'Ctrl'; + e.trigger.dispatchEvent(new Event(CLIPBOARD_ERROR_EVENT)); + + const { clipboardHandleTooltip = true } = e.trigger.dataset; + if (parseBoolean(clipboardHandleTooltip)) { + showTooltip(e.trigger, I18N_ERROR_MESSAGE); } - showTooltip(e.trigger, sprintf(__(`Press %{key}-C to copy`), { key })); } export default function initCopyToClipboard() { - const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]'); + const clipboard = new ClipboardJS('[data-clipboard-target], [data-clipboard-text]'); clipboard.on('success', genericSuccess); clipboard.on('error', genericError); @@ -74,6 +90,8 @@ export default function initCopyToClipboard() { clipboardData.setData('text/plain', json.text); clipboardData.setData('text/x-gfm', json.gfm); }); + + return clipboard; } /** @@ -89,3 +107,5 @@ export function clickCopyToClipboardButton(btnElement) { btnElement.click(); } + +export { CLIPBOARD_SUCCESS_EVENT, CLIPBOARD_ERROR_EVENT, I18N_ERROR_MESSAGE }; diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index 4698fcd4d42..c4e09efe263 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -4,6 +4,7 @@ import initUserPopovers from '../../user_popovers'; import highlightCurrentUser from './highlight_current_user'; import renderMath from './render_math'; import renderMermaid from './render_mermaid'; +import renderSandboxedMermaid from './render_sandboxed_mermaid'; import renderMetrics from './render_metrics'; // Render GitLab flavoured Markdown @@ -13,7 +14,11 @@ import renderMetrics from './render_metrics'; $.fn.renderGFM = function renderGFM() { syntaxHighlight(this.find('.js-syntax-highlight').get()); renderMath(this.find('.js-render-math')); - renderMermaid(this.find('.js-render-mermaid')); + if (gon.features?.sandboxedMermaid) { + renderSandboxedMermaid(this.find('.js-render-mermaid')); + } else { + renderMermaid(this.find('.js-render-mermaid')); + } highlightCurrentUser(this.find('.gfm-project_member').get()); initUserPopovers(this.find('.js-user-link').get()); diff --git a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js new file mode 100644 index 00000000000..1d54a1b0c04 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js @@ -0,0 +1,234 @@ +import $ from 'jquery'; +import { once, countBy } from 'lodash'; +import { __ } from '~/locale'; +import { + getBaseURL, + relativePathToAbsolute, + setUrlParams, + joinPaths, +} from '~/lib/utils/url_utility'; +import { darkModeEnabled } from '~/lib/utils/color_utils'; +import { setAttributes } from '~/lib/utils/dom_utils'; + +// Renders diagrams and flowcharts from text using Mermaid in any element with the +// `js-render-mermaid` class. +// +// Example markup: +// +// <pre class="js-render-mermaid"> +// graph TD; +// A-- > B; +// A-- > C; +// B-- > D; +// C-- > D; +// </pre> +// + +const SANDBOX_FRAME_PATH = '/-/sandbox/mermaid'; +// This is an arbitrary number; Can be iterated upon when suitable. +const MAX_CHAR_LIMIT = 2000; +// Max # of mermaid blocks that can be rendered in a page. +const MAX_MERMAID_BLOCK_LIMIT = 50; +// Max # of `&` allowed in Chaining of links syntax +const MAX_CHAINING_OF_LINKS_LIMIT = 30; +const BUFFER_IFRAME_HEIGHT = 10; +// Keep a map of mermaid blocks we've already rendered. +const elsProcessingMap = new WeakMap(); +let renderedMermaidBlocks = 0; + +// Pages without any restrictions on mermaid rendering +const PAGES_WITHOUT_RESTRICTIONS = [ + // Group wiki + 'groups:wikis:show', + 'groups:wikis:edit', + 'groups:wikis:create', + + // Project wiki + 'projects:wikis:show', + 'projects:wikis:edit', + 'projects:wikis:create', + + // Project files + 'projects:show', + 'projects:blob:show', +]; + +function shouldLazyLoadMermaidBlock(source) { + /** + * If source contains `&`, which means that it might + * contain Chaining of links a new syntax in Mermaid. + */ + if (countBy(source)['&'] > MAX_CHAINING_OF_LINKS_LIMIT) { + return true; + } + + return false; +} + +function fixElementSource(el) { + // Mermaid doesn't like `<br />` tags, so collapse all like tags into `<br>`, which is parsed correctly. + const source = el.textContent?.replace(/<br\s*\/>/g, '<br>'); + + // Remove any extra spans added by the backend syntax highlighting. + Object.assign(el, { textContent: source }); + + return { source }; +} + +function getSandboxFrameSrc() { + const path = joinPaths(gon.relative_url_root || '', SANDBOX_FRAME_PATH); + if (!darkModeEnabled()) { + return path; + } + const absoluteUrl = relativePathToAbsolute(path, getBaseURL()); + return setUrlParams({ darkMode: darkModeEnabled() }, absoluteUrl); +} + +function renderMermaidEl(el, source) { + const iframeEl = document.createElement('iframe'); + setAttributes(iframeEl, { + src: getSandboxFrameSrc(), + sandbox: 'allow-scripts', + frameBorder: 0, + scrolling: 'no', + width: '100%', + }); + + // Add the original source into the DOM + // to allow Copy-as-GFM to access it. + const sourceEl = document.createElement('text'); + sourceEl.textContent = source; + sourceEl.classList.add('gl-display-none'); + + const wrapper = document.createElement('div'); + wrapper.appendChild(iframeEl); + wrapper.appendChild(sourceEl); + + el.closest('pre').replaceWith(wrapper); + + // Event Listeners + iframeEl.addEventListener('load', () => { + // Potential risk associated with '*' discussed in below thread + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74414#note_735183398 + iframeEl.contentWindow.postMessage(source, '*'); + }); + + window.addEventListener( + 'message', + (event) => { + if (event.origin !== 'null' || event.source !== iframeEl.contentWindow) { + return; + } + const { h } = event.data; + iframeEl.height = `${h + BUFFER_IFRAME_HEIGHT}px`; + }, + false, + ); +} + +function renderMermaids($els) { + if (!$els.length) return; + + const pageName = document.querySelector('body').dataset.page; + + // A diagram may have been truncated in search results which will cause errors, so abort the render. + if (pageName === 'search:show') return; + + let renderedChars = 0; + + $els.each((i, el) => { + // Skipping all the elements which we've already queued in requestIdleCallback + if (elsProcessingMap.has(el)) { + return; + } + + const { source } = fixElementSource(el); + /** + * Restrict the rendering to a certain amount of character + * and mermaid blocks to prevent mermaidjs from hanging + * up the entire thread and causing a DoS. + */ + if ( + !PAGES_WITHOUT_RESTRICTIONS.includes(pageName) && + ((source && source.length > MAX_CHAR_LIMIT) || + renderedChars > MAX_CHAR_LIMIT || + renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT || + shouldLazyLoadMermaidBlock(source)) + ) { + const html = ` + <div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-mermaid-container js-lazy-render-mermaid-container fade show" role="alert"> + <div> + <div> + <div class="js-warning-text"></div> + <div class="gl-alert-actions"> + <button type="button" class="js-lazy-render-mermaid btn gl-alert-action btn-warning btn-md gl-button">Display</button> + </div> + </div> + <button type="button" class="close" data-dismiss="alert" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + </div> + `; + + const $parent = $(el).parent(); + + if (!$parent.hasClass('lazy-alert-shown')) { + $parent.after(html); + $parent + .siblings() + .find('.js-warning-text') + .text( + __('Warning: Displaying this diagram might cause performance issues on this page.'), + ); + $parent.addClass('lazy-alert-shown'); + } + + return; + } + + renderedChars += source.length; + renderedMermaidBlocks += 1; + + const requestId = window.requestIdleCallback(() => { + renderMermaidEl(el, source); + }); + + elsProcessingMap.set(el, requestId); + }); +} + +const hookLazyRenderMermaidEvent = once(() => { + $(document.body).on('click', '.js-lazy-render-mermaid', function eventHandler() { + const parent = $(this).closest('.js-lazy-render-mermaid-container'); + const pre = parent.prev(); + + const el = pre.find('.js-render-mermaid'); + + parent.remove(); + + // sandbox update + const element = el.get(0); + const { source } = fixElementSource(element); + + renderMermaidEl(element, source); + }); +}); + +export default function renderMermaid($els) { + if (!$els.length) return; + + const visibleMermaids = $els.filter(function filter() { + return $(this).closest('details').length === 0 && $(this).is(':visible'); + }); + + renderMermaids(visibleMermaids); + + $els.closest('details').one('toggle', function toggle() { + if (this.open) { + renderMermaids($(this).find('.js-render-mermaid')); + } + }); + + hookLazyRenderMermaidEvent(); +} diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index a548b283142..679940d1317 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -124,13 +124,6 @@ const writeButtonSelector = '.js-md-write-button'; lastTextareaPreviewed = null; const markdownToolbar = $('.md-header-toolbar'); -$.fn.setupMarkdownPreview = function () { - const $form = $(this); - $form.find('textarea.markdown-area').on('input', () => { - markdownPreview.hideReferencedUsers($form); - }); -}; - $(document).on('markdown-preview:show', (e, $form) => { if (!$form) { return; |