Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-04-20 13:00:54 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-04-20 13:00:54 +0300
commit3cccd102ba543e02725d247893729e5c73b38295 (patch)
treef36a04ec38517f5deaaacb5acc7d949688d1e187 /app/assets/javascripts/content_editor
parent205943281328046ef7b4528031b90fbda70c75ac (diff)
Add latest changes from gitlab-org/gitlab@14-10-stable-eev14.10.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
-rw-r--r--app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue146
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue3
-rw-r--r--app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue13
-rw-r--r--app/assets/javascripts/content_editor/components/loading_indicator.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/image.vue32
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/media.vue51
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js43
-rw-r--r--app/assets/javascripts/content_editor/extensions/diagram.js56
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/playable.js11
-rw-r--r--app/assets/javascripts/content_editor/services/code_block_language_loader.js283
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js19
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js10
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js11
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js8
-rw-r--r--app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js4
-rw-r--r--app/assets/javascripts/content_editor/services/upload_helpers.js24
17 files changed, 658 insertions, 61 deletions
diff --git a/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue b/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue
new file mode 100644
index 00000000000..87f22a27856
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue
@@ -0,0 +1,146 @@
+<script>
+import {
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
+import { BubbleMenu } from '@tiptap/vue-2';
+import codeBlockLanguageLoader from '../services/code_block_language_loader';
+import CodeBlockHighlight from '../extensions/code_block_highlight';
+import Diagram from '../extensions/diagram';
+import Frontmatter from '../extensions/frontmatter';
+import EditorStateObserver from './editor_state_observer.vue';
+
+const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name];
+
+export default {
+ components: {
+ BubbleMenu,
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ EditorStateObserver,
+ },
+ directives: {
+ GlTooltip,
+ },
+ inject: ['tiptapEditor'],
+ data() {
+ return {
+ selectedLanguage: {},
+ filterTerm: '',
+ filteredLanguages: [],
+ };
+ },
+ watch: {
+ filterTerm: {
+ handler(val) {
+ this.filteredLanguages = codeBlockLanguageLoader.filterLanguages(val);
+ },
+ immediate: true,
+ },
+ },
+ methods: {
+ shouldShow: ({ editor }) => {
+ return CODE_BLOCK_NODE_TYPES.some((type) => editor.isActive(type));
+ },
+
+ getSelectedLanguage() {
+ const { language } = this.tiptapEditor.getAttributes(this.getCodeBlockType());
+
+ this.selectedLanguage = codeBlockLanguageLoader.findLanguageBySyntax(language);
+ },
+
+ async setSelectedLanguage(language) {
+ this.selectedLanguage = language;
+
+ await codeBlockLanguageLoader.loadLanguages([language.syntax]);
+
+ this.tiptapEditor.commands.setCodeBlock({ language: this.selectedLanguage.syntax });
+ },
+
+ tippyOnBeforeUpdate(tippy, props) {
+ if (props.getReferenceClientRect) {
+ // eslint-disable-next-line no-param-reassign
+ props.getReferenceClientRect = () => {
+ const { view } = this.tiptapEditor;
+ const { from } = this.tiptapEditor.state.selection;
+
+ for (let { node } = view.domAtPos(from); node; node = node.parentElement) {
+ if (node.nodeName?.toLowerCase() === 'pre') {
+ return node.getBoundingClientRect();
+ }
+ }
+
+ return new DOMRect(-1000, -1000, 0, 0);
+ };
+ }
+ },
+
+ deleteCodeBlock() {
+ this.tiptapEditor.chain().focus().deleteNode(this.getCodeBlockType()).run();
+ },
+
+ getCodeBlockType() {
+ return (
+ CODE_BLOCK_NODE_TYPES.find((type) => this.tiptapEditor.isActive(type)) ||
+ CodeBlockHighlight.name
+ );
+ },
+ },
+};
+</script>
+<template>
+ <bubble-menu
+ data-testid="code-block-bubble-menu"
+ class="gl-shadow gl-rounded-base"
+ :editor="tiptapEditor"
+ plugin-key="bubbleMenuCodeBlock"
+ :should-show="shouldShow"
+ :tippy-options="{ onBeforeUpdate: tippyOnBeforeUpdate }"
+ >
+ <editor-state-observer @transaction="getSelectedLanguage">
+ <gl-button-group>
+ <gl-dropdown contenteditable="false" boundary="viewport" :text="selectedLanguage.label">
+ <template #header>
+ <gl-search-box-by-type
+ v-model="filterTerm"
+ :clear-button-title="__('Clear')"
+ :placeholder="__('Search')"
+ />
+ </template>
+
+ <template #highlighted-items>
+ <gl-dropdown-item :key="selectedLanguage.syntax" is-check-item :is-checked="true">
+ {{ selectedLanguage.label }}
+ </gl-dropdown-item>
+ </template>
+
+ <gl-dropdown-item
+ v-for="language in filteredLanguages"
+ v-show="selectedLanguage.syntax !== language.syntax"
+ :key="language.syntax"
+ @click="setSelectedLanguage(language)"
+ >
+ {{ language.label }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <gl-button
+ v-gl-tooltip
+ variant="default"
+ category="primary"
+ size="medium"
+ :aria-label="__('Delete code block')"
+ :title="__('Delete code block')"
+ icon="remove"
+ @click="deleteCodeBlock"
+ />
+ </gl-button-group>
+ </editor-state-observer>
+ </bubble-menu>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index a942c9f1149..5b3f4f4ddf2 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -5,6 +5,7 @@ import ContentEditorAlert from './content_editor_alert.vue';
import ContentEditorProvider from './content_editor_provider.vue';
import EditorStateObserver from './editor_state_observer.vue';
import FormattingBubbleMenu from './formatting_bubble_menu.vue';
+import CodeBlockBubbleMenu from './code_block_bubble_menu.vue';
import TopToolbar from './top_toolbar.vue';
import LoadingIndicator from './loading_indicator.vue';
@@ -16,6 +17,7 @@ export default {
TiptapEditorContent,
TopToolbar,
FormattingBubbleMenu,
+ CodeBlockBubbleMenu,
EditorStateObserver,
},
props: {
@@ -89,6 +91,7 @@ export default {
<top-toolbar ref="toolbar" class="gl-mb-4" />
<div class="gl-relative">
<formatting-bubble-menu />
+ <code-block-bubble-menu />
<tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
<loading-indicator />
</div>
diff --git a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
index 14a553ff30b..103079534bc 100644
--- a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
@@ -3,6 +3,10 @@ import { GlButtonGroup } from '@gitlab/ui';
import { BubbleMenu } from '@tiptap/vue-2';
import { BUBBLE_MENU_TRACKING_ACTION } from '../constants';
import trackUIControl from '../services/track_ui_control';
+import Code from '../extensions/code';
+import CodeBlockHighlight from '../extensions/code_block_highlight';
+import Diagram from '../extensions/diagram';
+import Frontmatter from '../extensions/frontmatter';
import ToolbarButton from './toolbar_button.vue';
export default {
@@ -16,6 +20,14 @@ export default {
trackToolbarControlExecution({ contentType, value }) {
trackUIControl({ action: BUBBLE_MENU_TRACKING_ACTION, property: contentType, value });
},
+
+ shouldShow: ({ editor, from, to }) => {
+ if (from === to) return false;
+
+ const exclude = [Code.name, CodeBlockHighlight.name, Diagram.name, Frontmatter.name];
+
+ return !exclude.some((type) => editor.isActive(type));
+ },
},
};
</script>
@@ -24,6 +36,7 @@ export default {
data-testid="formatting-bubble-menu"
class="gl-shadow gl-rounded-base"
:editor="tiptapEditor"
+ :should-show="shouldShow"
>
<gl-button-group>
<toolbar-button
diff --git a/app/assets/javascripts/content_editor/components/loading_indicator.vue b/app/assets/javascripts/content_editor/components/loading_indicator.vue
index 5b9383d6e11..620324adb06 100644
--- a/app/assets/javascripts/content_editor/components/loading_indicator.vue
+++ b/app/assets/javascripts/content_editor/components/loading_indicator.vue
@@ -30,6 +30,7 @@ export default {
>
<div
v-if="isLoading"
+ data-testid="content-editor-loading-indicator"
class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0"
>
<div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/image.vue b/app/assets/javascripts/content_editor/components/wrappers/image.vue
deleted file mode 100644
index 5b81e5fddcc..00000000000
--- a/app/assets/javascripts/content_editor/components/wrappers/image.vue
+++ /dev/null
@@ -1,32 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import { NodeViewWrapper } from '@tiptap/vue-2';
-
-export default {
- name: 'ImageWrapper',
- components: {
- NodeViewWrapper,
- GlLoadingIcon,
- },
- props: {
- node: {
- type: Object,
- required: true,
- },
- },
-};
-</script>
-<template>
- <node-view-wrapper class="gl-display-inline-block">
- <span class="gl-relative">
- <img
- data-testid="image"
- class="gl-max-w-full gl-h-auto"
- :title="node.attrs.title"
- :class="{ 'gl-opacity-5': node.attrs.uploading }"
- :src="node.attrs.src"
- />
- <gl-loading-icon v-if="node.attrs.uploading" class="gl-absolute gl-left-50p gl-top-half" />
- </span>
- </node-view-wrapper>
-</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/media.vue b/app/assets/javascripts/content_editor/components/wrappers/media.vue
new file mode 100644
index 00000000000..37119bdd066
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/media.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { NodeViewWrapper } from '@tiptap/vue-2';
+
+const tagNameMap = {
+ image: 'img',
+ video: 'video',
+ audio: 'audio',
+};
+
+export default {
+ name: 'MediaWrapper',
+ components: {
+ NodeViewWrapper,
+ GlLoadingIcon,
+ },
+ props: {
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ tagName() {
+ return tagNameMap[this.node.type.name] || 'img';
+ },
+ },
+};
+</script>
+<template>
+ <node-view-wrapper class="gl-display-inline-block">
+ <span class="gl-relative" :class="{ [`media-container ${tagName}-container`]: true }">
+ <gl-loading-icon v-if="node.attrs.uploading" class="gl-absolute gl-left-50p gl-top-half" />
+ <component
+ :is="tagName"
+ data-testid="media"
+ :class="{
+ 'gl-max-w-full gl-h-auto': tagName !== 'audio',
+ 'gl-opacity-5': node.attrs.uploading,
+ }"
+ :title="node.attrs.title || node.attrs.alt"
+ :alt="node.attrs.alt"
+ :src="node.attrs.src"
+ controls="true"
+ />
+ <a v-if="tagName !== 'img'" :href="node.attrs.canonicalSrc || node.attrs.src" @click.prevent>
+ {{ node.attrs.title || node.attrs.alt }}
+ </a>
+ </span>
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
index 204ac07d401..61f379fc0a2 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -1,10 +1,21 @@
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
-import { lowlight } from 'lowlight/lib/all';
+import { textblockTypeInputRule } from '@tiptap/core';
+import codeBlockLanguageLoader from '../services/code_block_language_loader';
const extractLanguage = (element) => element.getAttribute('lang');
+export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
+export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
export default CodeBlockLowlight.extend({
isolating: true,
+ exitOnArrowDown: false,
+
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ languageLoader: codeBlockLanguageLoader,
+ };
+ },
addAttributes() {
return {
@@ -18,16 +29,40 @@ export default CodeBlockLowlight.extend({
},
};
},
+ addInputRules() {
+ const { languageLoader } = this.options;
+ const getAttributes = (match) => languageLoader?.loadLanguageFromInputRule(match) || {};
+
+ return [
+ textblockTypeInputRule({
+ find: backtickInputRegex,
+ type: this.type,
+ getAttributes,
+ }),
+ textblockTypeInputRule({
+ find: tildeInputRegex,
+ type: this.type,
+ getAttributes,
+ }),
+ ];
+ },
+ parseHTML() {
+ return [
+ ...(this.parent?.() || []),
+ {
+ tag: 'div.markdown-code-block',
+ skip: true,
+ },
+ ];
+ },
renderHTML({ HTMLAttributes }) {
return [
'pre',
{
...HTMLAttributes,
- class: `content-editor-code-block ${HTMLAttributes.class}`,
+ class: `content-editor-code-block ${gon.user_color_scheme} ${HTMLAttributes.class}`,
},
['code', {}, 0],
];
},
-}).configure({
- lowlight,
});
diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js
new file mode 100644
index 00000000000..d192b815092
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/diagram.js
@@ -0,0 +1,56 @@
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import CodeBlockHighlight from './code_block_highlight';
+
+export default CodeBlockHighlight.extend({
+ name: 'diagram',
+
+ isolating: true,
+
+ addAttributes() {
+ return {
+ language: {
+ default: null,
+ parseHTML: (element) => {
+ return element.dataset.diagram;
+ },
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ tag: '[data-diagram]',
+ getContent(element, schema) {
+ const source = atob(element.dataset.diagramSrc.replace('data:text/plain;base64,', ''));
+ const node = schema.node('paragraph', {}, [schema.text(source)]);
+ return node.content;
+ },
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes: { language, ...HTMLAttributes } }) {
+ return [
+ 'div',
+ [
+ 'pre',
+ {
+ language,
+ class: `content-editor-code-block code highlight`,
+ ...HTMLAttributes,
+ },
+ ['code', {}, 0],
+ ],
+ ];
+ },
+
+ addCommands() {
+ return {};
+ },
+
+ addInputRules() {
+ return [];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js
index 519f7f168ce..311db8151cb 100644
--- a/app/assets/javascripts/content_editor/extensions/image.js
+++ b/app/assets/javascripts/content_editor/extensions/image.js
@@ -1,6 +1,6 @@
import { Image } from '@tiptap/extension-image';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
-import ImageWrapper from '../components/wrappers/image.vue';
+import MediaWrapper from '../components/wrappers/media.vue';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
const resolveImageEl = (element) =>
@@ -78,6 +78,6 @@ export default Image.extend({
];
},
addNodeView() {
- return VueNodeViewRenderer(ImageWrapper);
+ return VueNodeViewRenderer(MediaWrapper);
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js
index 0062bc563db..2c5269377c5 100644
--- a/app/assets/javascripts/content_editor/extensions/playable.js
+++ b/app/assets/javascripts/content_editor/extensions/playable.js
@@ -1,6 +1,8 @@
/* eslint-disable @gitlab/require-i18n-strings */
import { Node } from '@tiptap/core';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import MediaWrapper from '../components/wrappers/media.vue';
const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType);
@@ -11,6 +13,9 @@ export default Node.create({
addAttributes() {
return {
+ uploading: {
+ default: false,
+ },
src: {
default: null,
parseHTML: (element) => {
@@ -60,7 +65,11 @@ export default Node.create({
...this.extraElementAttrs,
},
],
- ['a', { href: node.attrs.src }, node.attrs.alt],
+ ['a', { href: node.attrs.src }, node.attrs.title || node.attrs.alt || ''],
];
},
+
+ addNodeView() {
+ return VueNodeViewRenderer(MediaWrapper);
+ },
});
diff --git a/app/assets/javascripts/content_editor/services/code_block_language_loader.js b/app/assets/javascripts/content_editor/services/code_block_language_loader.js
new file mode 100644
index 00000000000..081400cfd9a
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/code_block_language_loader.js
@@ -0,0 +1,283 @@
+import { lowlight } from 'lowlight/lib/core';
+import { __, sprintf } from '~/locale';
+
+/* eslint-disable @gitlab/require-i18n-strings */
+// List of languages referenced from https://github.com/wooorm/lowlight#data
+const CODE_BLOCK_LANGUAGES = [
+ { syntax: '1c', label: '1C:Enterprise' },
+ { syntax: 'abnf', label: 'Augmented Backus-Naur Form' },
+ { syntax: 'accesslog', label: 'Apache Access Log' },
+ { syntax: 'actionscript', variants: 'as', label: 'ActionScript' },
+ { syntax: 'ada', label: 'Ada' },
+ { syntax: 'angelscript', variants: 'asc', label: 'AngelScript' },
+ { syntax: 'apache', variants: 'apacheconf', label: 'Apache config' },
+ { syntax: 'applescript', variants: 'osascript', label: 'AppleScript' },
+ { syntax: 'arcade', label: 'ArcGIS Arcade' },
+ { syntax: 'arduino', variants: 'ino', label: 'Arduino' },
+ { syntax: 'armasm', variants: 'arm', label: 'ARM Assembly' },
+ { syntax: 'asciidoc', variants: 'adoc', label: 'AsciiDoc' },
+ { syntax: 'aspectj', label: 'AspectJ' },
+ { syntax: 'autohotkey', variants: 'ahk', label: 'AutoHotkey' },
+ { syntax: 'autoit', label: 'AutoIt' },
+ { syntax: 'avrasm', label: 'AVR Assembly' },
+ { syntax: 'awk', label: 'Awk' },
+ { syntax: 'axapta', variants: 'x++', label: 'X++' },
+ { syntax: 'bash', variants: 'sh', label: 'Bash' },
+ { syntax: 'basic', label: 'BASIC' },
+ { syntax: 'bnf', label: 'Backus-Naur Form' },
+ { syntax: 'brainfuck', variants: 'bf', label: 'Brainfuck' },
+ { syntax: 'c', variants: 'h', label: 'C' },
+ { syntax: 'cal', label: 'C/AL' },
+ { syntax: 'capnproto', variants: 'capnp', label: "Cap'n Proto" },
+ { syntax: 'ceylon', label: 'Ceylon' },
+ { syntax: 'clean', variants: 'icl, dcl', label: 'Clean' },
+ { syntax: 'clojure', variants: 'clj, edn', label: 'Clojure' },
+ { syntax: 'clojure-repl', label: 'Clojure REPL' },
+ { syntax: 'cmake', variants: 'cmake.in', label: 'CMake' },
+ { syntax: 'coffeescript', variants: 'coffee, cson, iced', label: 'CoffeeScript' },
+ { syntax: 'coq', label: 'Coq' },
+ { syntax: 'cos', variants: 'cls', label: 'Caché Object Script' },
+ { syntax: 'cpp', variants: 'cc, c++, h++, hpp, hh, hxx, cxx', label: 'C++' },
+ { syntax: 'crmsh', variants: 'crm, pcmk', label: 'crmsh' },
+ { syntax: 'crystal', variants: 'cr', label: 'Crystal' },
+ { syntax: 'csharp', variants: 'cs, c#', label: 'C#' },
+ { syntax: 'csp', label: 'CSP' },
+ { syntax: 'css', label: 'CSS' },
+ { syntax: 'd', label: 'D' },
+ { syntax: 'dart', label: 'Dart' },
+ { syntax: 'delphi', variants: 'dpr, dfm, pas, pascal', label: 'Delphi' },
+ { syntax: 'diff', variants: 'patch', label: 'Diff' },
+ { syntax: 'django', variants: 'jinja', label: 'Django' },
+ { syntax: 'dns', variants: 'bind, zone', label: 'DNS Zone' },
+ { syntax: 'dockerfile', variants: 'docker', label: 'Dockerfile' },
+ { syntax: 'dos', variants: 'bat, cmd', label: 'Batch file (DOS)' },
+ { syntax: 'dsconfig', label: 'DSConfig' },
+ { syntax: 'dts', label: 'Device Tree' },
+ { syntax: 'dust', variants: 'dst', label: 'Dust' },
+ { syntax: 'ebnf', label: 'Extended Backus-Naur Form' },
+ { syntax: 'elixir', variants: 'ex, exs', label: 'Elixir' },
+ { syntax: 'elm', label: 'Elm' },
+ { syntax: 'erb', label: 'ERB' },
+ { syntax: 'erlang', variants: 'erl', label: 'Erlang' },
+ { syntax: 'erlang-repl', label: 'Erlang REPL' },
+ { syntax: 'excel', variants: 'xlsx, xls', label: 'Excel formulae' },
+ { syntax: 'fix', label: 'FIX' },
+ { syntax: 'flix', label: 'Flix' },
+ { syntax: 'fortran', variants: 'f90, f95', label: 'Fortran' },
+ { syntax: 'fsharp', variants: 'fs, f#', label: 'F#' },
+ { syntax: 'gams', variants: 'gms', label: 'GAMS' },
+ { syntax: 'gauss', variants: 'gss', label: 'GAUSS' },
+ { syntax: 'gcode', variants: 'nc', label: 'G-code (ISO 6983)' },
+ { syntax: 'gherkin', variants: 'feature', label: 'Gherkin' },
+ { syntax: 'glsl', label: 'GLSL' },
+ { syntax: 'gml', label: 'GML' },
+ { syntax: 'go', variants: 'golang', label: 'Go' },
+ { syntax: 'golo', label: 'Golo' },
+ { syntax: 'gradle', label: 'Gradle' },
+ { syntax: 'graphql', variants: 'gql', label: 'GraphQL' },
+ { syntax: 'groovy', label: 'Groovy' },
+ { syntax: 'haml', label: 'HAML' },
+ {
+ syntax: 'handlebars',
+ variants: 'hbs, html.hbs, html.handlebars, htmlbars',
+ label: 'Handlebars',
+ },
+ { syntax: 'haskell', variants: 'hs', label: 'Haskell' },
+ { syntax: 'haxe', variants: 'hx', label: 'Haxe' },
+ { syntax: 'hsp', label: 'HSP' },
+ { syntax: 'http', variants: 'https', label: 'HTTP' },
+ { syntax: 'hy', variants: 'hylang', label: 'Hy' },
+ { syntax: 'inform7', variants: 'i7', label: 'Inform 7' },
+ { syntax: 'ini', variants: 'toml', label: 'TOML, also INI' },
+ { syntax: 'irpf90', label: 'IRPF90' },
+ { syntax: 'isbl', label: 'ISBL' },
+ { syntax: 'java', variants: 'jsp', label: 'Java' },
+ { syntax: 'javascript', variants: 'js, jsx, mjs, cjs', label: 'Javascript' },
+ { syntax: 'jboss-cli', variants: 'wildfly-cli', label: 'JBoss CLI' },
+ { syntax: 'json', label: 'JSON' },
+ { syntax: 'julia', label: 'Julia' },
+ { syntax: 'julia-repl', variants: 'jldoctest', label: 'Julia REPL' },
+ { syntax: 'kotlin', variants: 'kt, kts', label: 'Kotlin' },
+ { syntax: 'lasso', variants: 'ls, lassoscript', label: 'Lasso' },
+ { syntax: 'latex', variants: 'tex', label: 'LaTeX' },
+ { syntax: 'ldif', label: 'LDIF' },
+ { syntax: 'leaf', label: 'Leaf' },
+ { syntax: 'less', label: 'Less' },
+ { syntax: 'lisp', label: 'Lisp' },
+ { syntax: 'livecodeserver', label: 'LiveCode' },
+ { syntax: 'livescript', variants: 'ls', label: 'LiveScript' },
+ { syntax: 'llvm', label: 'LLVM IR' },
+ { syntax: 'lsl', label: 'LSL (Linden Scripting Language)' },
+ { syntax: 'lua', label: 'Lua' },
+ { syntax: 'makefile', variants: 'mk, mak, make', label: 'Makefile' },
+ { syntax: 'markdown', variants: 'md, mkdown, mkd', label: 'Markdown' },
+ { syntax: 'mathematica', variants: 'mma, wl', label: 'Mathematica' },
+ { syntax: 'matlab', label: 'Matlab' },
+ { syntax: 'maxima', label: 'Maxima' },
+ { syntax: 'mel', label: 'MEL' },
+ { syntax: 'mercury', variants: 'm, moo', label: 'Mercury' },
+ { syntax: 'mipsasm', variants: 'mips', label: 'MIPS Assembly' },
+ { syntax: 'mizar', label: 'Mizar' },
+ { syntax: 'mojolicious', label: 'Mojolicious' },
+ { syntax: 'monkey', label: 'Monkey' },
+ { syntax: 'moonscript', variants: 'moon', label: 'MoonScript' },
+ { syntax: 'n1ql', label: 'N1QL' },
+ { syntax: 'nestedtext', variants: 'nt', label: 'Nested Text' },
+ { syntax: 'nginx', variants: 'nginxconf', label: 'Nginx config' },
+ { syntax: 'nim', label: 'Nim' },
+ { syntax: 'nix', variants: 'nixos', label: 'Nix' },
+ { syntax: 'node-repl', label: 'Node REPL' },
+ { syntax: 'nsis', label: 'NSIS' },
+ {
+ syntax: 'objectivec',
+ variants: 'mm, objc, obj-c, obj-c++, objective-c++',
+ label: 'Objective-C',
+ },
+ { syntax: 'ocaml', variants: 'ml', label: 'OCaml' },
+ { syntax: 'openscad', variants: 'scad', label: 'OpenSCAD' },
+ { syntax: 'oxygene', label: 'Oxygene' },
+ { syntax: 'parser3', label: 'Parser3' },
+ { syntax: 'perl', variants: 'pl, pm', label: 'Perl' },
+ { syntax: 'pf', variants: 'pf.conf', label: 'Packet Filter config' },
+ { syntax: 'pgsql', variants: 'postgres, postgresql', label: 'PostgreSQL' },
+ { syntax: 'php', label: 'PHP' },
+ { syntax: 'php-template', label: 'PHP template' },
+ { syntax: 'plaintext', variants: 'text, txt', label: 'Plain text' },
+ { syntax: 'pony', label: 'Pony' },
+ { syntax: 'powershell', variants: 'pwsh, ps, ps1', label: 'PowerShell' },
+ { syntax: 'processing', variants: 'pde', label: 'Processing' },
+ { syntax: 'profile', label: 'Python profiler' },
+ { syntax: 'prolog', label: 'Prolog' },
+ { syntax: 'properties', label: '.properties' },
+ { syntax: 'protobuf', label: 'Protocol Buffers' },
+ { syntax: 'puppet', variants: 'pp', label: 'Puppet' },
+ { syntax: 'purebasic', variants: 'pb, pbi', label: 'PureBASIC' },
+ { syntax: 'python', variants: 'py, gyp, ipython', label: 'Python' },
+ { syntax: 'python-repl', variants: 'pycon', label: 'Python REPL' },
+ { syntax: 'q', variants: 'k, kdb', label: 'Q' },
+ { syntax: 'qml', variants: 'qt', label: 'QML' },
+ { syntax: 'r', label: 'R' },
+ { syntax: 'reasonml', variants: 're', label: 'ReasonML' },
+ { syntax: 'rib', label: 'RenderMan RIB' },
+ { syntax: 'roboconf', variants: 'graph, instances', label: 'Roboconf' },
+ { syntax: 'routeros', variants: 'mikrotik', label: 'Microtik RouterOS script' },
+ { syntax: 'rsl', label: 'RenderMan RSL' },
+ { syntax: 'ruby', variants: 'rb, gemspec, podspec, thor, irb', label: 'Ruby' },
+ { syntax: 'ruleslanguage', label: 'Oracle Rules Language' },
+ { syntax: 'rust', variants: 'rs', label: 'Rust' },
+ { syntax: 'sas', label: 'SAS' },
+ { syntax: 'scala', label: 'Scala' },
+ { syntax: 'scheme', label: 'Scheme' },
+ { syntax: 'scilab', variants: 'sci', label: 'Scilab' },
+ { syntax: 'scss', label: 'SCSS' },
+ { syntax: 'shell', variants: 'console, shellsession', label: 'Shell Session' },
+ { syntax: 'smali', label: 'Smali' },
+ { syntax: 'smalltalk', variants: 'st', label: 'Smalltalk' },
+ { syntax: 'sml', variants: 'ml', label: 'SML (Standard ML)' },
+ { syntax: 'sqf', label: 'SQF' },
+ { syntax: 'sql', label: 'SQL' },
+ { syntax: 'stan', variants: 'stanfuncs', label: 'Stan' },
+ { syntax: 'stata', variants: 'do, ado', label: 'Stata' },
+ { syntax: 'step21', variants: 'p21, step, stp', label: 'STEP Part 21' },
+ { syntax: 'stylus', variants: 'styl', label: 'Stylus' },
+ { syntax: 'subunit', label: 'SubUnit' },
+ { syntax: 'swift', label: 'Swift' },
+ { syntax: 'taggerscript', label: 'Tagger Script' },
+ { syntax: 'tap', label: 'Test Anything Protocol' },
+ { syntax: 'tcl', variants: 'tk', label: 'Tcl' },
+ { syntax: 'thrift', label: 'Thrift' },
+ { syntax: 'tp', label: 'TP' },
+ { syntax: 'twig', variants: 'craftcms', label: 'Twig' },
+ { syntax: 'typescript', variants: 'ts, tsx', label: 'TypeScript' },
+ { syntax: 'vala', label: 'Vala' },
+ { syntax: 'vbnet', variants: 'vb', label: 'Visual Basic .NET' },
+ { syntax: 'vbscript', variants: 'vbs', label: 'VBScript' },
+ { syntax: 'vbscript-html', label: 'VBScript in HTML' },
+ { syntax: 'verilog', variants: 'v, sv, svh', label: 'Verilog' },
+ { syntax: 'vhdl', label: 'VHDL' },
+ { syntax: 'vim', label: 'Vim Script' },
+ { syntax: 'wasm', label: 'WebAssembly' },
+ { syntax: 'wren', label: 'Wren' },
+ { syntax: 'x86asm', label: 'Intel x86 Assembly' },
+ { syntax: 'xl', variants: 'tao', label: 'XL' },
+ {
+ syntax: 'xml',
+ variants: 'html, xhtml, rss, atom, xjb, xsd, xsl, plist, wsf, svg',
+ label: 'HTML, XML',
+ },
+ { syntax: 'xquery', variants: 'xpath, xq', label: 'XQuery' },
+ { syntax: 'yaml', variants: 'yml', label: 'YAML' },
+ { syntax: 'zephir', variants: 'zep', label: 'Zephir' },
+];
+/* eslint-enable @gitlab/require-i18n-strings */
+
+const codeBlockLanguageLoader = {
+ lowlight,
+
+ allLanguages: CODE_BLOCK_LANGUAGES,
+
+ findLanguageBySyntax(value) {
+ const lowercaseValue = value?.toLowerCase() || 'plaintext';
+ return (
+ this.allLanguages.find(
+ ({ syntax, variants }) =>
+ syntax === lowercaseValue || variants?.toLowerCase().split(', ').includes(lowercaseValue),
+ ) || {
+ syntax: lowercaseValue,
+ label: sprintf(__(`Custom (%{language})`), { language: lowercaseValue }),
+ }
+ );
+ },
+
+ filterLanguages(value) {
+ if (!value) return this.allLanguages;
+
+ const lowercaseValue = value?.toLowerCase() || '';
+ return this.allLanguages.filter(
+ ({ syntax, label, variants }) =>
+ syntax.toLowerCase().includes(lowercaseValue) ||
+ label.toLowerCase().includes(lowercaseValue) ||
+ variants?.toLowerCase().includes(lowercaseValue),
+ );
+ },
+
+ isLanguageLoaded(language) {
+ return this.lowlight.registered(language);
+ },
+
+ loadLanguagesFromDOM(domTree) {
+ const languages = [];
+
+ domTree.querySelectorAll('pre').forEach((preElement) => {
+ languages.push(preElement.getAttribute('lang'));
+ });
+
+ return this.loadLanguages(languages);
+ },
+
+ loadLanguageFromInputRule(match) {
+ const { syntax } = this.findLanguageBySyntax(match[1]);
+
+ this.loadLanguages([syntax]);
+
+ return { language: syntax };
+ },
+
+ loadLanguages(languageList = []) {
+ const loaders = languageList
+ .filter((languageName) => !this.isLanguageLoaded(languageName))
+ .map((languageName) => {
+ return import(
+ /* webpackChunkName: 'highlight.language.js' */ `highlight.js/lib/languages/${languageName}`
+ )
+ .then(({ default: language }) => {
+ this.lowlight.registerLanguage(languageName, language);
+ })
+ .catch(() => false);
+ });
+
+ return Promise.all(loaders);
+ },
+};
+
+export default codeBlockLanguageLoader;
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index c5638da2daf..56badf965ee 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -3,11 +3,12 @@ import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } fro
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
- constructor({ tiptapEditor, serializer, deserializer, eventHub }) {
+ constructor({ tiptapEditor, serializer, deserializer, eventHub, languageLoader }) {
this._tiptapEditor = tiptapEditor;
this._serializer = serializer;
this._deserializer = deserializer;
this._eventHub = eventHub;
+ this._languageLoader = languageLoader;
}
get tiptapEditor() {
@@ -34,23 +35,33 @@ export class ContentEditor {
}
async setSerializedContent(serializedContent) {
- const { _tiptapEditor: editor, _deserializer: deserializer, _eventHub: eventHub } = this;
+ const {
+ _tiptapEditor: editor,
+ _deserializer: deserializer,
+ _eventHub: eventHub,
+ _languageLoader: languageLoader,
+ } = this;
const { doc, tr } = editor.state;
const selection = TextSelection.create(doc, 0, doc.content.size);
try {
eventHub.$emit(LOADING_CONTENT_EVENT);
- const { document } = await deserializer.deserialize({
+ const result = await deserializer.deserialize({
schema: editor.schema,
content: serializedContent,
});
- if (document) {
+ if (Object.keys(result).length !== 0) {
+ const { document, dom } = result;
+
+ await languageLoader.loadLanguagesFromDOM(dom);
+
tr.setSelection(selection)
.replaceSelectionWith(document, false)
.setMeta('preventUpdate', true);
editor.view.dispatch(tr);
}
+
eventHub.$emit(LOADING_SUCCESS_EVENT);
} catch (e) {
eventHub.$emit(LOADING_ERROR_EVENT, e);
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index d9d39a387d0..af19a0ab0e4 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -1,5 +1,6 @@
import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
+import { lowlight } from 'lowlight/lib/core';
import eventHubFactory from '~/helpers/event_hub_factory';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment';
@@ -14,6 +15,7 @@ import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
+import Diagram from '../extensions/diagram';
import Division from '../extensions/division';
import Document from '../extensions/document';
import Dropcursor from '../extensions/dropcursor';
@@ -58,6 +60,7 @@ import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
import createMarkdownDeserializer from './markdown_deserializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
+import languageLoader from './code_block_language_loader';
const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
new Editor({
@@ -91,12 +94,13 @@ export const createContentEditor = ({
BulletList,
Code,
ColorChip,
- CodeBlockHighlight,
+ CodeBlockHighlight.configure({ lowlight, languageLoader }),
DescriptionItem,
DescriptionList,
Details,
DetailsContent,
Document,
+ Diagram,
Division,
Dropcursor,
Emoji,
@@ -105,7 +109,7 @@ export const createContentEditor = ({
FootnoteDefinition,
FootnoteReference,
FootnotesSection,
- Frontmatter,
+ Frontmatter.configure({ lowlight }),
Gapcursor,
HardBreak,
Heading,
@@ -144,5 +148,5 @@ export const createContentEditor = ({
const serializer = createMarkdownSerializer({ serializerConfig });
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
- return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer });
+ return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer, languageLoader });
};
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index eaaf69c3068..c2be7bc9195 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -13,6 +13,7 @@ import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
import Division from '../extensions/division';
+import Diagram from '../extensions/diagram';
import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure';
import FigureCaption from '../extensions/figure_caption';
@@ -48,6 +49,7 @@ import Video from '../extensions/video';
import WordBreak from '../extensions/word_break';
import {
isPlainURL,
+ renderCodeBlock,
renderHardBreak,
renderTable,
renderTableCell,
@@ -130,13 +132,8 @@ const defaultSerializerConfig = {
}
},
[BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
- [CodeBlockHighlight.name]: (state, node) => {
- state.write(`\`\`\`${node.attrs.language || ''}\n`);
- state.text(node.textContent, false);
- state.ensureNewLine();
- state.write('```');
- state.closeBlock(node);
- },
+ [CodeBlockHighlight.name]: renderCodeBlock,
+ [Diagram.name]: renderCodeBlock,
[Division.name]: (state, node) => {
if (node.attrs.className?.includes('js-markdown-code')) {
state.renderInline(node);
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 5fdd294aa96..3e48434c6f9 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -341,3 +341,11 @@ export function renderImage(state, node) {
export function renderPlayable(state, node) {
renderImage(state, node);
}
+
+export function renderCodeBlock(state, node) {
+ state.write(`\`\`\`${node.attrs.language || ''}\n`);
+ state.text(node.textContent, false);
+ state.ensureNewLine();
+ state.write('```');
+ state.closeBlock(node);
+}
diff --git a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
index eb1e4885ba6..b844b414343 100644
--- a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
+++ b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
@@ -8,12 +8,12 @@ import {
INPUT_RULE_TRACKING_ACTION,
} from '../constants';
-const trackKeyboardShortcut = (contentType, commandFn, shortcut) => () => {
+const trackKeyboardShortcut = (contentType, commandFn, shortcut) => (...args) => {
Tracking.event(undefined, KEYBOARD_SHORTCUT_TRACKING_ACTION, {
label: CONTENT_EDITOR_TRACKING_LABEL,
property: `${contentType}.${shortcut}`,
});
- return commandFn();
+ return commandFn(...args);
};
const trackInputRule = (contentType, inputRule) => {
diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js
index 1abecb8f414..ed2c4b39131 100644
--- a/app/assets/javascripts/content_editor/services/upload_helpers.js
+++ b/app/assets/javascripts/content_editor/services/upload_helpers.js
@@ -5,6 +5,16 @@ import { extractFilename, readFileAsDataURL } from './utils';
export const acceptedMimes = {
image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
+ audio: [
+ 'audio/basic',
+ 'audio/mid',
+ 'audio/mpeg',
+ 'audio/x-aiff',
+ 'audio/ogg',
+ 'audio/vorbis',
+ 'audio/vnd.wav',
+ ],
+ video: ['video/mp4', 'video/quicktime'],
};
const extractAttachmentLinkUrl = (html) => {
@@ -50,11 +60,11 @@ export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
return extractAttachmentLinkUrl(rendered);
};
-const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
+const uploadContent = async ({ type, editor, file, uploadsPath, renderMarkdown, eventHub }) => {
const encodedSrc = await readFileAsDataURL(file);
const { view } = editor;
- editor.commands.setImage({ uploading: true, src: encodedSrc });
+ editor.commands.insertContent({ type, attrs: { uploading: true, src: encodedSrc } });
const { state } = view;
const position = state.selection.from - 1;
@@ -74,7 +84,7 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub
} catch (e) {
editor.commands.deleteRange({ from: position, to: position + 1 });
eventHub.$emit('alert', {
- message: __('An error occurred while uploading the image. Please try again.'),
+ message: __('An error occurred while uploading the file. Please try again.'),
variant: VARIANT_DANGER,
});
}
@@ -114,10 +124,12 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eve
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
if (!file) return false;
- if (acceptedMimes.image.includes(file?.type)) {
- uploadImage({ editor, file, uploadsPath, renderMarkdown, eventHub });
+ for (const [type, mimes] of Object.entries(acceptedMimes)) {
+ if (mimes.includes(file?.type)) {
+ uploadContent({ type, editor, file, uploadsPath, renderMarkdown, eventHub });
- return true;
+ return true;
+ }
}
uploadAttachment({ editor, file, uploadsPath, renderMarkdown, eventHub });