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';
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>
......
......@@ -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
......
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { textblockTypeInputRule } from '@tiptap/core';
import { isFunction } from 'lodash';
import codeBlockLanguageLoader from '../services/code_block_language_loader';
const extractLanguage = (element) => element.getAttribute('lang');
const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
const loadLanguageFromInputRule = (languageLoader) => (match) => {
const language = match[1];
if (isFunction(languageLoader?.loadLanguages)) {
languageLoader.loadLanguages([language]);
}
return {
language,
};
};
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: {},
languageLoader: codeBlockLanguageLoader,
};
},
......@@ -42,26 +31,36 @@ export default CodeBlockLowlight.extend({
},
addInputRules() {
const { languageLoader } = this.options;
const getAttributes = (match) => languageLoader?.loadLanguageFromInputRule(match) || {};
return [
textblockTypeInputRule({
find: backtickInputRegex,
type: this.type,
getAttributes: loadLanguageFromInputRule(languageLoader),
getAttributes,
}),
textblockTypeInputRule({
find: tildeInputRegex,
type: this.type,
getAttributes: loadLanguageFromInputRule(languageLoader),
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],
];
......
......@@ -60,7 +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 CodeBlockLanguageLoader from './code_block_language_loader';
import languageLoader from './code_block_language_loader';
const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
new Editor({
......@@ -86,7 +86,6 @@ export const createContentEditor = ({
const eventHub = eventHubFactory();
const languageLoader = new CodeBlockLanguageLoader(lowlight);
const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
Audio,
......
......@@ -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) => {
......
......@@ -10969,6 +10969,9 @@ msgstr ""
msgid "CurrentUser|Start an Ultimate trial"
msgstr ""
msgid "Custom (%{language})"
msgstr ""
msgid "Custom Attributes"
msgstr ""
......@@ -11965,6 +11968,9 @@ msgstr ""
msgid "Delete badge"
msgstr ""
msgid "Delete code block"
msgstr ""
msgid "Delete column"
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 {
} from '~/content_editor/constants';
import { createTestEditor } from '../test_utils';
describe('content_editor/components/top_toolbar', () => {
describe('content_editor/components/formatting_bubble_menu', () => {
let wrapper;
let trackingSpy;
let tiptapEditor;
......
......@@ -23,7 +23,7 @@ describe('content_editor/extensions/frontmatter', () => {
});
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 = '``` ';
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', () => {
let languageLoader;
......@@ -12,7 +14,43 @@ describe('content_editor/services/code_block_language_loader', () => {
.mockImplementation((language) => lowlight.languages.push(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', () => {
......@@ -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', () => {
it('returns true when a language is registered', async () => {
const language = 'javascript';
......
# frozen_string_literal: true
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]'
content_editor_testid = '[data-testid="content-editor"] [contenteditable].ProseMirror'
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 [:shift, :left]
find(content_editor_testid).send_keys 'Typing text in the content editor'
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
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