diff options
Diffstat (limited to 'app/assets/javascripts/content_editor')
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 }); |