Commit f2fb962a authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '328641-content-editor-images' into 'master'

Add toolbar button to insert images in Content Editor

See merge request gitlab-org/gitlab!63939
parents 07e6bdb9 404f20a4
<script>
import {
GlDropdown,
GlDropdownForm,
GlButton,
GlFormInputGroup,
GlDropdownDivider,
GlDropdownItem,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
import { acceptedMimes } from '../extensions/image';
import { getImageAlt } from '../services/utils';
export default {
components: {
GlDropdown,
GlDropdownForm,
GlFormInputGroup,
GlDropdownDivider,
GlDropdownItem,
GlButton,
},
directives: {
GlTooltip,
},
props: {
tiptapEditor: {
type: TiptapEditor,
required: true,
},
},
data() {
return {
imgSrc: '',
};
},
methods: {
resetFields() {
this.imgSrc = '';
this.$refs.fileSelector.value = '';
},
insertImage() {
this.tiptapEditor
.chain()
.focus()
.setImage({
src: this.imgSrc,
canonicalSrc: this.imgSrc,
alt: getImageAlt(this.imgSrc),
})
.run();
this.resetFields();
this.emitExecute();
},
emitExecute(source = 'url') {
this.$emit('execute', { contentType: 'image', value: source });
},
openFileUpload() {
this.$refs.fileSelector.click();
},
onFileSelect(e) {
this.tiptapEditor
.chain()
.focus()
.uploadImage({
file: e.target.files[0],
})
.run();
this.resetFields();
this.emitExecute('upload');
},
},
acceptedMimes,
};
</script>
<template>
<gl-dropdown
v-gl-tooltip
:aria-label="__('Insert image')"
:title="__('Insert image')"
size="small"
category="tertiary"
icon="media"
@hidden="resetFields()"
>
<gl-dropdown-form class="gl-px-3!">
<gl-form-input-group v-model="imgSrc" :placeholder="__('Image URL')">
<template #append>
<gl-button variant="confirm" @click="insertImage">{{ __('Insert') }}</gl-button>
</template>
</gl-form-input-group>
</gl-dropdown-form>
<gl-dropdown-divider />
<gl-dropdown-item @click="openFileUpload">
{{ __('Upload image') }}
</gl-dropdown-item>
<input
ref="fileSelector"
type="file"
name="content_editor_image"
:accept="$options.acceptedMimes"
class="gl-display-none"
@change="onFileSelect"
/>
</gl-dropdown>
</template>
...@@ -43,7 +43,7 @@ export default { ...@@ -43,7 +43,7 @@ export default {
}, },
mounted() { mounted() {
this.tiptapEditor.on('selectionUpdate', ({ editor }) => { this.tiptapEditor.on('selectionUpdate', ({ editor }) => {
const { 'data-canonical-src': canonicalSrc, href } = editor.getAttributes(linkContentType); const { canonicalSrc, href } = editor.getAttributes(linkContentType);
this.linkHref = canonicalSrc || href; this.linkHref = canonicalSrc || href;
}); });
...@@ -56,7 +56,7 @@ export default { ...@@ -56,7 +56,7 @@ export default {
.unsetLink() .unsetLink()
.setLink({ .setLink({
href: this.linkHref, href: this.linkHref,
'data-canonical-src': this.linkHref, canonicalSrc: this.linkHref,
}) })
.run(); .run();
......
...@@ -4,6 +4,7 @@ import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from ' ...@@ -4,6 +4,7 @@ import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '
import { ContentEditor } from '../services/content_editor'; import { ContentEditor } from '../services/content_editor';
import Divider from './divider.vue'; import Divider from './divider.vue';
import ToolbarButton from './toolbar_button.vue'; import ToolbarButton from './toolbar_button.vue';
import ToolbarImageButton from './toolbar_image_button.vue';
import ToolbarLinkButton from './toolbar_link_button.vue'; import ToolbarLinkButton from './toolbar_link_button.vue';
import ToolbarTableButton from './toolbar_table_button.vue'; import ToolbarTableButton from './toolbar_table_button.vue';
import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue'; import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue';
...@@ -18,6 +19,7 @@ export default { ...@@ -18,6 +19,7 @@ export default {
ToolbarTextStyleDropdown, ToolbarTextStyleDropdown,
ToolbarLinkButton, ToolbarLinkButton,
ToolbarTableButton, ToolbarTableButton,
ToolbarImageButton,
Divider, Divider,
}, },
mixins: [trackingMixin], mixins: [trackingMixin],
...@@ -89,6 +91,12 @@ export default { ...@@ -89,6 +91,12 @@ export default {
@execute="trackToolbarControlExecution" @execute="trackToolbarControlExecution"
/> />
<divider /> <divider />
<toolbar-image-button
ref="imageButton"
data-testid="image"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button <toolbar-button
data-testid="blockquote" data-testid="blockquote"
content-type="blockquote" content-type="blockquote"
...@@ -140,3 +148,8 @@ export default { ...@@ -140,3 +148,8 @@ export default {
/> />
</div> </div>
</template> </template>
<style>
.gl-spinner-container {
text-align: left;
}
</style>
...@@ -38,11 +38,11 @@ export const tiptapExtension = Link.extend({ ...@@ -38,11 +38,11 @@ export const tiptapExtension = Link.extend({
}; };
}, },
}, },
'data-canonical-src': { canonicalSrc: {
default: null, default: null,
parseHTML: (element) => { parseHTML: (element) => {
return { return {
href: element.dataset.canonicalSrc, canonicalSrc: element.dataset.canonicalSrc,
}; };
}, },
}, },
...@@ -57,7 +57,7 @@ export const serializer = { ...@@ -57,7 +57,7 @@ export const serializer = {
return '['; return '[';
}, },
close(state, mark) { close(state, mark) {
const href = mark.attrs['data-canonical-src'] || mark.attrs.href; const href = mark.attrs.canonicalSrc || mark.attrs.href;
return `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`; return `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`;
}, },
}; };
...@@ -17489,6 +17489,9 @@ msgstr "" ...@@ -17489,6 +17489,9 @@ msgstr ""
msgid "Input the remote repository URL" msgid "Input the remote repository URL"
msgstr "" msgstr ""
msgid "Insert"
msgstr ""
msgid "Insert a %{rows}x%{cols} table." msgid "Insert a %{rows}x%{cols} table."
msgstr "" msgstr ""
...@@ -35334,6 +35337,9 @@ msgstr "" ...@@ -35334,6 +35337,9 @@ msgstr ""
msgid "Upload file" msgid "Upload file"
msgstr "" msgstr ""
msgid "Upload image"
msgstr ""
msgid "Upload license" msgid "Upload license"
msgstr "" msgstr ""
......
import { GlButton, GlFormInputGroup } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarImageButton from '~/content_editor/components/toolbar_image_button.vue';
import { configure as configureImageExtension } from '~/content_editor/extensions/image';
import { createTestEditor, mockChainedCommands } from '../test_utils';
describe('content_editor/components/toolbar_image_button', () => {
let wrapper;
let editor;
const buildWrapper = () => {
wrapper = mountExtended(ToolbarImageButton, {
propsData: {
tiptapEditor: editor,
},
});
};
const findImageURLInput = () =>
wrapper.findComponent(GlFormInputGroup).find('input[type="text"]');
const findApplyImageButton = () => wrapper.findComponent(GlButton);
const selectFile = async (file) => {
const input = wrapper.find({ ref: 'fileSelector' });
// override the property definition because `input.files` isn't directly modifyable
Object.defineProperty(input.element, 'files', { value: [file], writable: true });
await input.trigger('change');
};
beforeEach(() => {
const { tiptapExtension: Image } = configureImageExtension({
renderMarkdown: jest.fn(),
uploadsPath: '/uploads/',
});
editor = createTestEditor({
extensions: [Image],
});
buildWrapper();
});
afterEach(() => {
editor.destroy();
wrapper.destroy();
});
it('sets the image to the value in the URL input when "Insert" button is clicked', async () => {
const commands = mockChainedCommands(editor, ['focus', 'setImage', 'run']);
await findImageURLInput().setValue('https://example.com/img.jpg');
await findApplyImageButton().trigger('click');
expect(commands.focus).toHaveBeenCalled();
expect(commands.setImage).toHaveBeenCalledWith({
alt: 'img',
src: 'https://example.com/img.jpg',
canonicalSrc: 'https://example.com/img.jpg',
});
expect(commands.run).toHaveBeenCalled();
expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'url' }]);
});
it('uploads the selected image when file input changes', async () => {
const commands = mockChainedCommands(editor, ['focus', 'uploadImage', 'run']);
const file = new File(['foo'], 'foo.png', { type: 'image/png' });
await selectFile(file);
expect(commands.focus).toHaveBeenCalled();
expect(commands.uploadImage).toHaveBeenCalledWith({ file });
expect(commands.run).toHaveBeenCalled();
expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'upload' }]);
});
});
...@@ -76,15 +76,17 @@ describe('content_editor/components/toolbar_link_button', () => { ...@@ -76,15 +76,17 @@ describe('content_editor/components/toolbar_link_button', () => {
expect(commands.unsetLink).toHaveBeenCalled(); expect(commands.unsetLink).toHaveBeenCalled();
expect(commands.setLink).toHaveBeenCalledWith({ expect(commands.setLink).toHaveBeenCalledWith({
href: 'https://example', href: 'https://example',
'data-canonical-src': 'https://example', canonicalSrc: 'https://example',
}); });
expect(commands.run).toHaveBeenCalled(); expect(commands.run).toHaveBeenCalled();
expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]);
}); });
describe('on selection update', () => { describe('on selection update', () => {
it('updates link input box with canonical-src if present', async () => { it('updates link input box with canonical-src if present', async () => {
jest.spyOn(editor, 'getAttributes').mockReturnValueOnce({ jest.spyOn(editor, 'getAttributes').mockReturnValueOnce({
'data-canonical-src': 'uploads/my-file.zip', canonicalSrc: 'uploads/my-file.zip',
href: '/username/my-project/uploads/abcdefgh133535/my-file.zip', href: '/username/my-project/uploads/abcdefgh133535/my-file.zip',
}); });
...@@ -130,9 +132,11 @@ describe('content_editor/components/toolbar_link_button', () => { ...@@ -130,9 +132,11 @@ describe('content_editor/components/toolbar_link_button', () => {
expect(commands.focus).toHaveBeenCalled(); expect(commands.focus).toHaveBeenCalled();
expect(commands.setLink).toHaveBeenCalledWith({ expect(commands.setLink).toHaveBeenCalledWith({
href: 'https://example', href: 'https://example',
'data-canonical-src': 'https://example', canonicalSrc: 'https://example',
}); });
expect(commands.run).toHaveBeenCalled(); expect(commands.run).toHaveBeenCalled();
expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]);
}); });
}); });
......
...@@ -51,6 +51,7 @@ describe('content_editor/components/top_toolbar', () => { ...@@ -51,6 +51,7 @@ describe('content_editor/components/top_toolbar', () => {
${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }} ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }}
${'text-styles'} | ${{}} ${'text-styles'} | ${{}}
${'link'} | ${{}} ${'link'} | ${{}}
${'image'} | ${{}}
`('given a $testId toolbar control', ({ testId, controlProps }) => { `('given a $testId toolbar control', ({ testId, controlProps }) => {
beforeEach(() => { beforeEach(() => {
buildWrapper(); buildWrapper();
......
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