Commit 3b72b325 authored by Himanshu Kapoor's avatar Himanshu Kapoor Committed by Paul Slaughter

Add a dropdown to switch language in code blocks

Allow switching code language in code blocks in content editor
using a dropdown bubble menu.

Changelog: added
parent cc977bb1
<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>
...@@ -5,6 +5,7 @@ import ContentEditorAlert from './content_editor_alert.vue'; ...@@ -5,6 +5,7 @@ import ContentEditorAlert from './content_editor_alert.vue';
import ContentEditorProvider from './content_editor_provider.vue'; import ContentEditorProvider from './content_editor_provider.vue';
import EditorStateObserver from './editor_state_observer.vue'; import EditorStateObserver from './editor_state_observer.vue';
import FormattingBubbleMenu from './formatting_bubble_menu.vue'; import FormattingBubbleMenu from './formatting_bubble_menu.vue';
import CodeBlockBubbleMenu from './code_block_bubble_menu.vue';
import TopToolbar from './top_toolbar.vue'; import TopToolbar from './top_toolbar.vue';
import LoadingIndicator from './loading_indicator.vue'; import LoadingIndicator from './loading_indicator.vue';
...@@ -16,6 +17,7 @@ export default { ...@@ -16,6 +17,7 @@ export default {
TiptapEditorContent, TiptapEditorContent,
TopToolbar, TopToolbar,
FormattingBubbleMenu, FormattingBubbleMenu,
CodeBlockBubbleMenu,
EditorStateObserver, EditorStateObserver,
}, },
props: { props: {
...@@ -89,6 +91,7 @@ export default { ...@@ -89,6 +91,7 @@ export default {
<top-toolbar ref="toolbar" class="gl-mb-4" /> <top-toolbar ref="toolbar" class="gl-mb-4" />
<div class="gl-relative"> <div class="gl-relative">
<formatting-bubble-menu /> <formatting-bubble-menu />
<code-block-bubble-menu />
<tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
<loading-indicator /> <loading-indicator />
</div> </div>
......
...@@ -3,6 +3,10 @@ import { GlButtonGroup } from '@gitlab/ui'; ...@@ -3,6 +3,10 @@ import { GlButtonGroup } from '@gitlab/ui';
import { BubbleMenu } from '@tiptap/vue-2'; import { BubbleMenu } from '@tiptap/vue-2';
import { BUBBLE_MENU_TRACKING_ACTION } from '../constants'; import { BUBBLE_MENU_TRACKING_ACTION } from '../constants';
import trackUIControl from '../services/track_ui_control'; 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'; import ToolbarButton from './toolbar_button.vue';
export default { export default {
...@@ -16,6 +20,14 @@ export default { ...@@ -16,6 +20,14 @@ export default {
trackToolbarControlExecution({ contentType, value }) { trackToolbarControlExecution({ contentType, value }) {
trackUIControl({ action: BUBBLE_MENU_TRACKING_ACTION, property: 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> </script>
...@@ -24,6 +36,7 @@ export default { ...@@ -24,6 +36,7 @@ export default {
data-testid="formatting-bubble-menu" data-testid="formatting-bubble-menu"
class="gl-shadow gl-rounded-base" class="gl-shadow gl-rounded-base"
:editor="tiptapEditor" :editor="tiptapEditor"
:should-show="shouldShow"
> >
<gl-button-group> <gl-button-group>
<toolbar-button <toolbar-button
......
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { textblockTypeInputRule } from '@tiptap/core'; import { textblockTypeInputRule } from '@tiptap/core';
import { isFunction } from 'lodash'; import codeBlockLanguageLoader from '../services/code_block_language_loader';
const extractLanguage = (element) => element.getAttribute('lang'); const extractLanguage = (element) => element.getAttribute('lang');
const backtickInputRegex = /^```([a-z]+)?[\s\n]$/; export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/; export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
const loadLanguageFromInputRule = (languageLoader) => (match) => {
const language = match[1];
if (isFunction(languageLoader?.loadLanguages)) {
languageLoader.loadLanguages([language]);
}
return {
language,
};
};
export default CodeBlockLowlight.extend({ export default CodeBlockLowlight.extend({
isolating: true, isolating: true,
exitOnArrowDown: false,
addOptions() { addOptions() {
return { return {
...this.parent?.(), ...this.parent?.(),
languageLoader: {}, languageLoader: codeBlockLanguageLoader,
}; };
}, },
...@@ -42,26 +31,36 @@ export default CodeBlockLowlight.extend({ ...@@ -42,26 +31,36 @@ export default CodeBlockLowlight.extend({
}, },
addInputRules() { addInputRules() {
const { languageLoader } = this.options; const { languageLoader } = this.options;
const getAttributes = (match) => languageLoader?.loadLanguageFromInputRule(match) || {};
return [ return [
textblockTypeInputRule({ textblockTypeInputRule({
find: backtickInputRegex, find: backtickInputRegex,
type: this.type, type: this.type,
getAttributes: loadLanguageFromInputRule(languageLoader), getAttributes,
}), }),
textblockTypeInputRule({ textblockTypeInputRule({
find: tildeInputRegex, find: tildeInputRegex,
type: this.type, type: this.type,
getAttributes: loadLanguageFromInputRule(languageLoader), getAttributes,
}), }),
]; ];
}, },
parseHTML() {
return [
...(this.parent?.() || []),
{
tag: 'div.markdown-code-block',
skip: true,
},
];
},
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
return [ return [
'pre', 'pre',
{ {
...HTMLAttributes, ...HTMLAttributes,
class: `content-editor-code-block ${HTMLAttributes.class}`, class: `content-editor-code-block ${gon.user_color_scheme} ${HTMLAttributes.class}`,
}, },
['code', {}, 0], ['code', {}, 0],
]; ];
......
export default class CodeBlockLanguageLoader { import { lowlight } from 'lowlight/lib/core';
constructor(lowlight) { import { __, sprintf } from '~/locale';
this.lowlight = lowlight;
} /* 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) { isLanguageLoaded(language) {
return this.lowlight.registered(language); return this.lowlight.registered(language);
} },
loadLanguagesFromDOM(domTree) { loadLanguagesFromDOM(domTree) {
const languages = []; const languages = [];
...@@ -15,7 +253,15 @@ export default class CodeBlockLanguageLoader { ...@@ -15,7 +253,15 @@ export default class CodeBlockLanguageLoader {
}); });
return this.loadLanguages(languages); return this.loadLanguages(languages);
} },
loadLanguageFromInputRule(match) {
const { syntax } = this.findLanguageBySyntax(match[1]);
this.loadLanguages([syntax]);
return { language: syntax };
},
loadLanguages(languageList = []) { loadLanguages(languageList = []) {
const loaders = languageList const loaders = languageList
...@@ -31,5 +277,7 @@ export default class CodeBlockLanguageLoader { ...@@ -31,5 +277,7 @@ export default class CodeBlockLanguageLoader {
}); });
return Promise.all(loaders); return Promise.all(loaders);
} },
} };
export default codeBlockLanguageLoader;
...@@ -60,7 +60,7 @@ import { ContentEditor } from './content_editor'; ...@@ -60,7 +60,7 @@ import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer'; import createMarkdownSerializer from './markdown_serializer';
import createMarkdownDeserializer from './markdown_deserializer'; import createMarkdownDeserializer from './markdown_deserializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
import CodeBlockLanguageLoader from './code_block_language_loader'; import languageLoader from './code_block_language_loader';
const createTiptapEditor = ({ extensions = [], ...options } = {}) => const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
new Editor({ new Editor({
...@@ -86,7 +86,6 @@ export const createContentEditor = ({ ...@@ -86,7 +86,6 @@ export const createContentEditor = ({
const eventHub = eventHubFactory(); const eventHub = eventHubFactory();
const languageLoader = new CodeBlockLanguageLoader(lowlight);
const builtInContentEditorExtensions = [ const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown, eventHub }), Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
Audio, Audio,
......
...@@ -8,12 +8,12 @@ import { ...@@ -8,12 +8,12 @@ import {
INPUT_RULE_TRACKING_ACTION, INPUT_RULE_TRACKING_ACTION,
} from '../constants'; } from '../constants';
const trackKeyboardShortcut = (contentType, commandFn, shortcut) => () => { const trackKeyboardShortcut = (contentType, commandFn, shortcut) => (...args) => {
Tracking.event(undefined, KEYBOARD_SHORTCUT_TRACKING_ACTION, { Tracking.event(undefined, KEYBOARD_SHORTCUT_TRACKING_ACTION, {
label: CONTENT_EDITOR_TRACKING_LABEL, label: CONTENT_EDITOR_TRACKING_LABEL,
property: `${contentType}.${shortcut}`, property: `${contentType}.${shortcut}`,
}); });
return commandFn(); return commandFn(...args);
}; };
const trackInputRule = (contentType, inputRule) => { const trackInputRule = (contentType, inputRule) => {
......
...@@ -10969,6 +10969,9 @@ msgstr "" ...@@ -10969,6 +10969,9 @@ msgstr ""
msgid "CurrentUser|Start an Ultimate trial" msgid "CurrentUser|Start an Ultimate trial"
msgstr "" msgstr ""
msgid "Custom (%{language})"
msgstr ""
msgid "Custom Attributes" msgid "Custom Attributes"
msgstr "" msgstr ""
...@@ -11965,6 +11968,9 @@ msgstr "" ...@@ -11965,6 +11968,9 @@ msgstr ""
msgid "Delete badge" msgid "Delete badge"
msgstr "" msgstr ""
msgid "Delete code block"
msgstr ""
msgid "Delete column" msgid "Delete column"
msgstr "" msgstr ""
......
import { BubbleMenu } from '@tiptap/vue-2';
import { GlButton, GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import Vue from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import CodeBlockBubbleMenu from '~/content_editor/components/code_block_bubble_menu.vue';
import eventHubFactory from '~/helpers/event_hub_factory';
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader';
import { createTestEditor, emitEditorEvent } from '../test_utils';
describe('content_editor/components/code_block_bubble_menu', () => {
let wrapper;
let tiptapEditor;
let bubbleMenu;
let eventHub;
const buildEditor = () => {
tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] });
eventHub = eventHubFactory();
};
const buildWrapper = () => {
wrapper = mountExtended(CodeBlockBubbleMenu, {
provide: {
tiptapEditor,
eventHub,
},
});
};
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findDropdownItemsData = () =>
findDropdownItems().wrappers.map((x) => ({
text: x.text(),
visible: x.isVisible(),
checked: x.props('isChecked'),
}));
beforeEach(() => {
buildEditor();
buildWrapper();
});
afterEach(() => {
wrapper.destroy();
});
it('renders bubble menu component', async () => {
tiptapEditor.commands.insertContent('<pre>test</pre>');
bubbleMenu = wrapper.findComponent(BubbleMenu);
await emitEditorEvent({ event: 'transaction', tiptapEditor });
expect(bubbleMenu.props('editor')).toBe(tiptapEditor);
expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']);
});
it('selects plaintext language by default', async () => {
tiptapEditor.commands.insertContent('<pre>test</pre>');
bubbleMenu = wrapper.findComponent(BubbleMenu);
await emitEditorEvent({ event: 'transaction', tiptapEditor });
expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Plain text');
});
it('selects appropriate language based on the code block', async () => {
tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>');
bubbleMenu = wrapper.findComponent(BubbleMenu);
await emitEditorEvent({ event: 'transaction', tiptapEditor });
expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Javascript');
});
it("selects Custom (syntax) if the language doesn't exist in the list", async () => {
tiptapEditor.commands.insertContent('<pre lang="nomnoml">test</pre>');
bubbleMenu = wrapper.findComponent(BubbleMenu);
await emitEditorEvent({ event: 'transaction', tiptapEditor });
expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Custom (nomnoml)');
});
it('delete button deletes the code block', async () => {
tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>');
await wrapper.findComponent(GlButton).vm.$emit('click');
expect(tiptapEditor.getText()).toBe('');
});
describe('when opened and search is changed', () => {
beforeEach(async () => {
tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>');
wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'js');
await Vue.nextTick();
});
it('shows dropdown items', () => {
expect(findDropdownItemsData()).toEqual([
{ text: 'Javascript', visible: true, checked: true },
{ text: 'Java', visible: true, checked: false },
{ text: 'Javascript', visible: false, checked: false },
{ text: 'JSON', visible: true, checked: false },
]);
});
describe('when dropdown item is clicked', () => {
beforeEach(async () => {
jest.spyOn(codeBlockLanguageLoader, 'loadLanguages').mockResolvedValue();
findDropdownItems().at(1).vm.$emit('click');
await Vue.nextTick();
});
it('loads language', () => {
expect(codeBlockLanguageLoader.loadLanguages).toHaveBeenCalledWith(['java']);
});
it('sets code block', () => {
expect(tiptapEditor.getJSON()).toMatchObject({
content: [
{
type: 'codeBlock',
attrs: {
language: 'java',
},
},
],
});
});
it('updates selected dropdown', () => {
expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Java');
});
});
});
});
...@@ -9,7 +9,7 @@ import { ...@@ -9,7 +9,7 @@ import {
} from '~/content_editor/constants'; } from '~/content_editor/constants';
import { createTestEditor } from '../test_utils'; import { createTestEditor } from '../test_utils';
describe('content_editor/components/top_toolbar', () => { describe('content_editor/components/formatting_bubble_menu', () => {
let wrapper; let wrapper;
let trackingSpy; let trackingSpy;
let tiptapEditor; let tiptapEditor;
......
...@@ -23,7 +23,7 @@ describe('content_editor/extensions/frontmatter', () => { ...@@ -23,7 +23,7 @@ describe('content_editor/extensions/frontmatter', () => {
}); });
it('does not insert a frontmatter block when executing code block input rule', () => { it('does not insert a frontmatter block when executing code block input rule', () => {
const expectedDoc = doc(codeBlock('')); const expectedDoc = doc(codeBlock({ language: 'plaintext' }, ''));
const inputRuleText = '``` '; const inputRuleText = '``` ';
triggerNodeInputRule({ tiptapEditor, inputRuleText }); triggerNodeInputRule({ tiptapEditor, inputRuleText });
......
import CodeBlockLanguageBlocker from '~/content_editor/services/code_block_language_loader'; import codeBlockLanguageBlocker from '~/content_editor/services/code_block_language_loader';
import waitForPromises from 'helpers/wait_for_promises';
import { backtickInputRegex } from '~/content_editor/extensions/code_block_highlight';
describe('content_editor/services/code_block_language_loader', () => { describe('content_editor/services/code_block_language_loader', () => {
let languageLoader; let languageLoader;
...@@ -12,7 +14,43 @@ describe('content_editor/services/code_block_language_loader', () => { ...@@ -12,7 +14,43 @@ describe('content_editor/services/code_block_language_loader', () => {
.mockImplementation((language) => lowlight.languages.push(language)), .mockImplementation((language) => lowlight.languages.push(language)),
registered: jest.fn().mockImplementation((language) => lowlight.languages.includes(language)), registered: jest.fn().mockImplementation((language) => lowlight.languages.includes(language)),
}; };
languageLoader = new CodeBlockLanguageBlocker(lowlight); languageLoader = codeBlockLanguageBlocker;
languageLoader.lowlight = lowlight;
});
describe('findLanguageBySyntax', () => {
it.each`
syntax | language
${'javascript'} | ${{ syntax: 'javascript', label: 'Javascript' }}
${'js'} | ${{ syntax: 'javascript', label: 'Javascript' }}
${'jsx'} | ${{ syntax: 'javascript', label: 'Javascript' }}
`('returns a language by syntax and its variants', ({ syntax, language }) => {
expect(languageLoader.findLanguageBySyntax(syntax)).toMatchObject(language);
});
it('returns Custom (syntax) if the language does not exist', () => {
expect(languageLoader.findLanguageBySyntax('foobar')).toMatchObject({
syntax: 'foobar',
label: 'Custom (foobar)',
});
});
it('returns plaintext if no syntax is passed', () => {
expect(languageLoader.findLanguageBySyntax('')).toMatchObject({
syntax: 'plaintext',
label: 'Plain text',
});
});
});
describe('filterLanguages', () => {
it('filters languages by the given search term', () => {
expect(languageLoader.filterLanguages('ts')).toEqual([
{ label: 'Device Tree', syntax: 'dts' },
{ label: 'Kotlin', syntax: 'kotlin', variants: 'kt, kts' },
{ label: 'TypeScript', syntax: 'typescript', variants: 'ts, tsx' },
]);
});
}); });
describe('loadLanguages', () => { describe('loadLanguages', () => {
...@@ -56,6 +94,18 @@ describe('content_editor/services/code_block_language_loader', () => { ...@@ -56,6 +94,18 @@ describe('content_editor/services/code_block_language_loader', () => {
}); });
}); });
describe('loadLanguageFromInputRule', () => {
it('loads highlight.js language packages identified from the input rule', async () => {
const match = new RegExp(backtickInputRegex).exec('```js ');
const attrs = languageLoader.loadLanguageFromInputRule(match);
await waitForPromises();
expect(attrs).toEqual({ language: 'javascript' });
expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function));
});
});
describe('isLanguageLoaded', () => { describe('isLanguageLoaded', () => {
it('returns true when a language is registered', async () => { it('returns true when a language is registered', async () => {
const language = 'javascript'; const language = 'javascript';
......
# frozen_string_literal: true # frozen_string_literal: true
RSpec.shared_examples 'edits content using the content editor' do RSpec.shared_examples 'edits content using the content editor' do
it 'formats text as bold using bubble menu' do content_editor_testid = '[data-testid="content-editor"] [contenteditable].ProseMirror'
content_editor_testid = '[data-testid="content-editor"] [contenteditable]'
expect(page).to have_css(content_editor_testid) describe 'formatting bubble menu' do
it 'shows a formatting bubble menu for a regular paragraph' do
expect(page).to have_css(content_editor_testid)
find(content_editor_testid).send_keys 'Typing text in the content editor' find(content_editor_testid).send_keys 'Typing text in the content editor'
find(content_editor_testid).send_keys [:shift, :left] find(content_editor_testid).send_keys [:shift, :left]
expect(page).to have_css('[data-testid="formatting-bubble-menu"]') expect(page).to have_css('[data-testid="formatting-bubble-menu"]')
end
it 'does not show a formatting bubble menu for code' do
find(content_editor_testid).send_keys 'This is a `code`'
find(content_editor_testid).send_keys [:shift, :left]
expect(page).not_to have_css('[data-testid="formatting-bubble-menu"]')
end
end
describe 'code block bubble menu' do
it 'shows a code block bubble menu for a code block' do
find(content_editor_testid).send_keys '```js ' # trigger input rule
find(content_editor_testid).send_keys 'var a = 0'
find(content_editor_testid).send_keys [:shift, :left]
expect(page).not_to have_css('[data-testid="formatting-bubble-menu"]')
expect(page).to have_css('[data-testid="code-block-bubble-menu"]')
end
it 'sets code block type to "javascript" for `js`' do
find(content_editor_testid).send_keys '```js '
find(content_editor_testid).send_keys 'var a = 0'
expect(find('[data-testid="code-block-bubble-menu"]')).to have_text('Javascript')
end
it 'sets code block type to "Custom (nomnoml)" for `nomnoml`' do
find(content_editor_testid).send_keys '```nomnoml '
find(content_editor_testid).send_keys 'test'
expect(find('[data-testid="code-block-bubble-menu"]')).to have_text('Custom (nomnoml)')
end
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment