Commit 7e0f6195 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '218529-display-uploaded-images' into 'master'

Preview uploaded images in the SSE

See merge request gitlab-org/gitlab!36299
parents b9ab0bff 45020549
...@@ -83,7 +83,13 @@ export default { ...@@ -83,7 +83,13 @@ export default {
return this.editorMode === EDITOR_TYPES.wysiwyg; return this.editorMode === EDITOR_TYPES.wysiwyg;
}, },
customRenderers() { customRenderers() {
const imageRenderer = renderImage.build(this.mounts, this.project, this.branch, this.baseUrl); const imageRenderer = renderImage.build(
this.mounts,
this.project,
this.branch,
this.baseUrl,
this.$options.imageRepository,
);
return { return {
image: [imageRenderer], image: [imageRenderer],
}; };
......
...@@ -12,9 +12,11 @@ const imageRepository = () => { ...@@ -12,9 +12,11 @@ const imageRepository = () => {
.catch(() => flash(__('Something went wrong while inserting your image. Please try again.'))); .catch(() => flash(__('Something went wrong while inserting your image. Please try again.')));
}; };
const get = path => images.get(path);
const getAll = () => images; const getAll = () => images;
return { add, getAll }; return { add, get, getAll };
}; };
export default imageRepository; export default imageRepository;
...@@ -4,6 +4,8 @@ const canRender = ({ type }) => type === 'image'; ...@@ -4,6 +4,8 @@ const canRender = ({ type }) => type === 'image';
let metadata; let metadata;
const getCachedContent = basePath => metadata.imageRepository.get(basePath);
const isRelativeToCurrentDirectory = basePath => !basePath.startsWith('/'); const isRelativeToCurrentDirectory = basePath => !basePath.startsWith('/');
const extractSourceDirectory = url => { const extractSourceDirectory = url => {
...@@ -46,7 +48,11 @@ const generateSourceDirectory = basePath => { ...@@ -46,7 +48,11 @@ const generateSourceDirectory = basePath => {
return sourceDir || defaultSourceDir; return sourceDir || defaultSourceDir;
}; };
const resolveFullPath = originalSrc => { const resolveFullPath = (originalSrc, cachedContent) => {
if (cachedContent) {
return `data:image;base64,${cachedContent}`;
}
if (isAbsolute(originalSrc)) { if (isAbsolute(originalSrc)) {
return originalSrc; return originalSrc;
} }
...@@ -61,20 +67,22 @@ const resolveFullPath = originalSrc => { ...@@ -61,20 +67,22 @@ const resolveFullPath = originalSrc => {
const render = ({ destination: originalSrc, firstChild }, { skipChildren }) => { const render = ({ destination: originalSrc, firstChild }, { skipChildren }) => {
skipChildren(); skipChildren();
const cachedContent = getCachedContent(originalSrc);
return { return {
type: 'openTag', type: 'openTag',
tagName: 'img', tagName: 'img',
selfClose: true, selfClose: true,
attributes: { attributes: {
'data-original-src': !isAbsolute(originalSrc) ? originalSrc : '', 'data-original-src': !isAbsolute(originalSrc) || cachedContent ? originalSrc : '',
src: resolveFullPath(originalSrc), src: resolveFullPath(originalSrc, cachedContent),
alt: firstChild.literal, alt: firstChild.literal,
}, },
}; };
}; };
const build = (mounts = [], project, branch, baseUrl) => { const build = (mounts = [], project, branch, baseUrl, imageRepository) => {
metadata = { mounts, project, branch, baseUrl }; metadata = { mounts, project, branch, baseUrl, imageRepository };
return { canRender, render }; return { canRender, render };
}; };
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui'; import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui';
import { isSafeURL, joinPaths } from '~/lib/utils/url_utility'; import { isSafeURL, joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale'; import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IMAGE_TABS } from '../../constants'; import { IMAGE_TABS } from '../../constants';
import UploadImageTab from './upload_image_tab.vue'; import UploadImageTab from './upload_image_tab.vue';
...@@ -15,7 +14,6 @@ export default { ...@@ -15,7 +14,6 @@ export default {
GlTabs, GlTabs,
GlTab, GlTab,
}, },
mixins: [glFeatureFlagMixin()],
props: { props: {
imageRoot: { imageRoot: {
type: String, type: String,
...@@ -34,10 +32,10 @@ export default { ...@@ -34,10 +32,10 @@ export default {
}, },
modalTitle: __('Image details'), modalTitle: __('Image details'),
okTitle: __('Insert image'), okTitle: __('Insert image'),
urlTabTitle: __('By URL'), urlTabTitle: __('Link to an image'),
urlLabel: __('Image URL'), urlLabel: __('Image URL'),
descriptionLabel: __('Description'), descriptionLabel: __('Description'),
uploadTabTitle: __('Upload file'), uploadTabTitle: __('Upload an image'),
computed: { computed: {
altText() { altText() {
return this.description; return this.description;
...@@ -54,7 +52,7 @@ export default { ...@@ -54,7 +52,7 @@ export default {
this.$refs.modal.show(); this.$refs.modal.show();
}, },
onOk(event) { onOk(event) {
if (this.glFeatures.sseImageUploads && this.tabIndex === IMAGE_TABS.UPLOAD_TAB) { if (this.tabIndex === IMAGE_TABS.UPLOAD_TAB) {
this.submitFile(event); this.submitFile(event);
return; return;
} }
...@@ -108,7 +106,7 @@ export default { ...@@ -108,7 +106,7 @@ export default {
:ok-title="$options.okTitle" :ok-title="$options.okTitle"
@ok="onOk" @ok="onOk"
> >
<gl-tabs v-if="glFeatures.sseImageUploads" v-model="tabIndex"> <gl-tabs v-model="tabIndex">
<!-- Upload file Tab --> <!-- Upload file Tab -->
<gl-tab :title="$options.uploadTabTitle"> <gl-tab :title="$options.uploadTabTitle">
<upload-image-tab ref="uploadImageTab" @input="setFile" /> <upload-image-tab ref="uploadImageTab" @input="setFile" />
...@@ -128,17 +126,6 @@ export default { ...@@ -128,17 +126,6 @@ export default {
</gl-tab> </gl-tab>
</gl-tabs> </gl-tabs>
<gl-form-group
v-else
class="gl-mt-5 gl-mb-3"
:label="$options.urlLabel"
label-for="url-input"
:state="!Boolean(urlError)"
:invalid-feedback="urlError"
>
<gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
</gl-form-group>
<!-- Description Input --> <!-- Description Input -->
<gl-form-group :label="$options.descriptionLabel" label-for="description-input"> <gl-form-group :label="$options.descriptionLabel" label-for="description-input">
<gl-form-input id="description-input" ref="descriptionInput" v-model="description" /> <gl-form-input id="description-input" ref="descriptionInput" v-model="description" />
......
...@@ -114,10 +114,9 @@ export default { ...@@ -114,10 +114,9 @@ export default {
if (file) { if (file) {
this.$emit('uploadImage', { file, imageUrl }); this.$emit('uploadImage', { file, imageUrl });
// TODO - ensure that the actual repo URL for the image is used in Markdown mode
} }
addImage(this.editorInstance, image); addImage(this.editorInstance, image, file);
}, },
onOpenInsertVideoModal() { onOpenInsertVideoModal() {
this.$refs.insertVideoModal.show(); this.$refs.insertVideoModal.show();
......
...@@ -34,6 +34,20 @@ const buildVideoIframe = src => { ...@@ -34,6 +34,20 @@ const buildVideoIframe = src => {
return wrapper; return wrapper;
}; };
const buildImg = (alt, originalSrc, file) => {
const img = document.createElement('img');
const src = file ? URL.createObjectURL(file) : originalSrc;
const attributes = { alt, src };
if (file) {
img.dataset.originalSrc = originalSrc;
}
Object.assign(img, attributes);
return img;
};
export const generateToolbarItem = config => { export const generateToolbarItem = config => {
const { icon, classes, event, command, tooltip, isDivider } = config; const { icon, classes, event, command, tooltip, isDivider } = config;
...@@ -59,7 +73,14 @@ export const addCustomEventListener = (editorApi, event, handler) => { ...@@ -59,7 +73,14 @@ export const addCustomEventListener = (editorApi, event, handler) => {
export const removeCustomEventListener = (editorApi, event, handler) => export const removeCustomEventListener = (editorApi, event, handler) =>
editorApi.eventManager.removeEventHandler(event, handler); editorApi.eventManager.removeEventHandler(event, handler);
export const addImage = ({ editor }, image) => editor.exec('AddImage', image); export const addImage = ({ editor }, { altText, imageUrl }, file) => {
if (editor.isWysiwygMode()) {
const img = buildImg(altText, imageUrl, file);
editor.getSquire().insertElement(img);
} else {
editor.insertText(`![${altText}](${imageUrl})`);
}
};
export const insertVideo = ({ editor }, url) => { export const insertVideo = ({ editor }, url) => {
const videoIframe = buildVideoIframe(url); const videoIframe = buildVideoIframe(url);
......
...@@ -16,9 +16,6 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController ...@@ -16,9 +16,6 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController
prepend_before_action :authenticate_user!, only: [:show] prepend_before_action :authenticate_user!, only: [:show]
before_action :assign_ref_and_path, only: [:show] before_action :assign_ref_and_path, only: [:show]
before_action :authorize_edit_tree!, only: [:show] before_action :authorize_edit_tree!, only: [:show]
before_action do
push_frontend_feature_flag(:sse_image_uploads)
end
feature_category :static_site_editor feature_category :static_site_editor
......
---
title: Enable the ability to upload images via the SSE
merge_request: 36299
author:
type: added
...@@ -107,13 +107,36 @@ The Static Site Editors supports Markdown files (`.md`, `.md.erb`) for editing t ...@@ -107,13 +107,36 @@ The Static Site Editors supports Markdown files (`.md`, `.md.erb`) for editing t
### Images ### Images
> - Support for adding images through the WYSIWYG editor [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216640) in GitLab 13.1. > - Support for adding images through the WYSIWYG editor [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216640) in GitLab 13.1.
> - Support for uploading images via the WYSIWYG editor [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218529) in GitLab 13.6.
You can add image files on the WYSIWYG mode by clicking the image icon (**{doc-image}**). #### Upload an image
From there, link to a URL, add optional [ALT text](https://moz.com/learn/seo/alt-text),
and you're done. The link can reference images already hosted in your project, an asset hosted You can upload image files via the WYSIWYG editor directly to the repository to default upload directory
`source/images`. To do so:
1. Click the image icon (**{doc-image}**).
1. Choose the **Upload file** tab.
1. Click **Choose file** to select a file from your computer.
1. Optional: add a description to the image for SEO and accessibility ([ALT text](https://moz.com/learn/seo/alt-text)).
1. Click **Insert image**.
The selected file can be any supported image file (`.png`, `.jpg`, `.jpeg`, `.gif`). The editor renders
thumbnail previews so you can verify the correct image is included and there aren't any references to
missing images.
#### Link to an image
You can also link to an image if you'd like:
1. Click the image icon (**{doc-image}**).
1. Choose the **Link to an image** tab.
1. Add the link to the image into the **Image URL** field (use the full path; relative paths are not supported yet).
1. Optional: add a description to the image for SEO and accessibility ([ALT text](https://moz.com/learn/seo/alt-text)).
1. Click **Insert image**.
The link can reference images already hosted in your project, an asset hosted
externally on a content delivery network, or any other external URL. The editor renders thumbnail previews externally on a content delivery network, or any other external URL. The editor renders thumbnail previews
so you can verify the correct image is included and there aren't any references to missing images. so you can verify the correct image is included and there aren't any references to missing images.
default directory (`source/images/`).
### Videos ### Videos
......
...@@ -4696,9 +4696,6 @@ msgstr "" ...@@ -4696,9 +4696,6 @@ msgstr ""
msgid "By %{user_name}" msgid "By %{user_name}"
msgstr "" msgstr ""
msgid "By URL"
msgstr ""
msgid "By clicking Register, I agree that I have read and accepted the %{company_name} %{linkStart}Terms of Use and Privacy Policy%{linkEnd}" msgid "By clicking Register, I agree that I have read and accepted the %{company_name} %{linkStart}Terms of Use and Privacy Policy%{linkEnd}"
msgstr "" msgstr ""
...@@ -16146,6 +16143,9 @@ msgstr "" ...@@ -16146,6 +16143,9 @@ msgstr ""
msgid "Link title is required" msgid "Link title is required"
msgstr "" msgstr ""
msgid "Link to an image"
msgstr ""
msgid "Link to go to GitLab pipeline documentation" msgid "Link to go to GitLab pipeline documentation"
msgstr "" msgstr ""
...@@ -29174,6 +29174,9 @@ msgstr "" ...@@ -29174,6 +29174,9 @@ msgstr ""
msgid "Upload a private key for your certificate" msgid "Upload a private key for your certificate"
msgstr "" msgstr ""
msgid "Upload an image"
msgstr ""
msgid "Upload file" msgid "Upload file"
msgstr "" msgstr ""
......
...@@ -3,9 +3,11 @@ import { mounts, project, branch, baseUrl } from '../../mock_data'; ...@@ -3,9 +3,11 @@ import { mounts, project, branch, baseUrl } from '../../mock_data';
describe('rich_content_editor/renderers/render_image', () => { describe('rich_content_editor/renderers/render_image', () => {
let renderer; let renderer;
let imageRepository;
beforeEach(() => { beforeEach(() => {
renderer = imageRenderer.build(mounts, project, branch, baseUrl); renderer = imageRenderer.build(mounts, project, branch, baseUrl, imageRepository);
imageRepository = { get: () => null };
}); });
describe('build', () => { describe('build', () => {
...@@ -27,6 +29,21 @@ describe('rich_content_editor/renderers/render_image', () => { ...@@ -27,6 +29,21 @@ describe('rich_content_editor/renderers/render_image', () => {
}); });
describe('render', () => { describe('render', () => {
let skipChildren;
let context;
let node;
beforeEach(() => {
skipChildren = jest.fn();
context = { skipChildren };
node = {
firstChild: {
type: 'img',
literal: 'Some Image',
},
};
});
it.each` it.each`
destination | isAbsolute | src destination | isAbsolute | src
${'http://test.host/absolute/path/to/image.png'} | ${true} | ${'http://test.host/absolute/path/to/image.png'} ${'http://test.host/absolute/path/to/image.png'} | ${true} | ${'http://test.host/absolute/path/to/image.png'}
...@@ -36,15 +53,8 @@ describe('rich_content_editor/renderers/render_image', () => { ...@@ -36,15 +53,8 @@ describe('rich_content_editor/renderers/render_image', () => {
${'./relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/./relative/to/current/image.png'} ${'./relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/./relative/to/current/image.png'}
${'../relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/../relative/to/current/image.png'} ${'../relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/../relative/to/current/image.png'}
`('returns an image with the correct attributes', ({ destination, isAbsolute, src }) => { `('returns an image with the correct attributes', ({ destination, isAbsolute, src }) => {
const skipChildren = jest.fn(); node.destination = destination;
const context = { skipChildren };
const node = {
destination,
firstChild: {
type: 'img',
literal: 'Some Image',
},
};
const result = renderer.render(node, context); const result = renderer.render(node, context);
expect(result).toEqual({ expect(result).toEqual({
...@@ -60,5 +70,27 @@ describe('rich_content_editor/renderers/render_image', () => { ...@@ -60,5 +70,27 @@ describe('rich_content_editor/renderers/render_image', () => {
expect(skipChildren).toHaveBeenCalled(); expect(skipChildren).toHaveBeenCalled();
}); });
it('renders an image if a cached image is found in the repository, use the base64 content as the source', () => {
const imageContent = 'some-content';
const originalSrc = 'path/to/image.png';
imageRepository.get = () => imageContent;
renderer = imageRenderer.build(mounts, project, branch, baseUrl, imageRepository);
node.destination = originalSrc;
const result = renderer.render(node, context);
expect(result).toEqual({
type: 'openTag',
tagName: 'img',
selfClose: true,
attributes: {
'data-original-src': originalSrc,
src: `data:image;base64,${imageContent}`,
alt: 'Some Image',
},
});
});
}); });
}); });
...@@ -91,12 +91,25 @@ describe('Editor Service', () => { ...@@ -91,12 +91,25 @@ describe('Editor Service', () => {
}); });
describe('addImage', () => { describe('addImage', () => {
it('calls the exec method on the instance', () => { const file = new File([], 'some-file.jpg');
const mockImage = { imageUrl: 'some/url.png', description: 'some description' }; const mockImage = { imageUrl: 'some/url.png', altText: 'some alt text' };
addImage(mockInstance, mockImage); it('calls the insertElement method on the squire instance when in WYSIWYG mode', () => {
jest.spyOn(URL, 'createObjectURL');
mockInstance.editor.isWysiwygMode.mockReturnValue(true);
mockInstance.editor.getSquire.mockReturnValue({ insertElement: jest.fn() });
addImage(mockInstance, mockImage, file);
expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalled();
expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(file);
});
it('calls the insertText method on the instance when in Markdown mode', () => {
mockInstance.editor.isWysiwygMode.mockReturnValue(false);
addImage(mockInstance, mockImage, file);
expect(mockInstance.editor.exec).toHaveBeenCalledWith('AddImage', mockImage); expect(mockInstance.editor.insertText).toHaveBeenCalledWith('![some alt text](some/url.png)');
}); });
}); });
......
...@@ -15,10 +15,7 @@ describe('Add Image Modal', () => { ...@@ -15,10 +15,7 @@ describe('Add Image Modal', () => {
const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' }); const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' });
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(AddImageModal, { wrapper = shallowMount(AddImageModal, { propsData });
provide: { glFeatures: { sseImageUploads: true } },
propsData,
});
}); });
describe('when content is loaded', () => { describe('when content is loaded', () => {
......
...@@ -180,7 +180,7 @@ describe('Rich Content Editor', () => { ...@@ -180,7 +180,7 @@ describe('Rich Content Editor', () => {
wrapper.vm.$refs.editor = mockInstance; wrapper.vm.$refs.editor = mockInstance;
findAddImageModal().vm.$emit('addImage', mockImage); findAddImageModal().vm.$emit('addImage', mockImage);
expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage); expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage, undefined);
}); });
}); });
......
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