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 {
return this.editorMode === EDITOR_TYPES.wysiwyg;
},
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 {
image: [imageRenderer],
};
......
......@@ -12,9 +12,11 @@ const imageRepository = () => {
.catch(() => flash(__('Something went wrong while inserting your image. Please try again.')));
};
const get = path => images.get(path);
const getAll = () => images;
return { add, getAll };
return { add, get, getAll };
};
export default imageRepository;
......@@ -4,6 +4,8 @@ const canRender = ({ type }) => type === 'image';
let metadata;
const getCachedContent = basePath => metadata.imageRepository.get(basePath);
const isRelativeToCurrentDirectory = basePath => !basePath.startsWith('/');
const extractSourceDirectory = url => {
......@@ -46,7 +48,11 @@ const generateSourceDirectory = basePath => {
return sourceDir || defaultSourceDir;
};
const resolveFullPath = originalSrc => {
const resolveFullPath = (originalSrc, cachedContent) => {
if (cachedContent) {
return `data:image;base64,${cachedContent}`;
}
if (isAbsolute(originalSrc)) {
return originalSrc;
}
......@@ -61,20 +67,22 @@ const resolveFullPath = originalSrc => {
const render = ({ destination: originalSrc, firstChild }, { skipChildren }) => {
skipChildren();
const cachedContent = getCachedContent(originalSrc);
return {
type: 'openTag',
tagName: 'img',
selfClose: true,
attributes: {
'data-original-src': !isAbsolute(originalSrc) ? originalSrc : '',
src: resolveFullPath(originalSrc),
'data-original-src': !isAbsolute(originalSrc) || cachedContent ? originalSrc : '',
src: resolveFullPath(originalSrc, cachedContent),
alt: firstChild.literal,
},
};
};
const build = (mounts = [], project, branch, baseUrl) => {
metadata = { mounts, project, branch, baseUrl };
const build = (mounts = [], project, branch, baseUrl, imageRepository) => {
metadata = { mounts, project, branch, baseUrl, imageRepository };
return { canRender, render };
};
......
......@@ -2,7 +2,6 @@
import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui';
import { isSafeURL, joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IMAGE_TABS } from '../../constants';
import UploadImageTab from './upload_image_tab.vue';
......@@ -15,7 +14,6 @@ export default {
GlTabs,
GlTab,
},
mixins: [glFeatureFlagMixin()],
props: {
imageRoot: {
type: String,
......@@ -34,10 +32,10 @@ export default {
},
modalTitle: __('Image details'),
okTitle: __('Insert image'),
urlTabTitle: __('By URL'),
urlTabTitle: __('Link to an image'),
urlLabel: __('Image URL'),
descriptionLabel: __('Description'),
uploadTabTitle: __('Upload file'),
uploadTabTitle: __('Upload an image'),
computed: {
altText() {
return this.description;
......@@ -54,7 +52,7 @@ export default {
this.$refs.modal.show();
},
onOk(event) {
if (this.glFeatures.sseImageUploads && this.tabIndex === IMAGE_TABS.UPLOAD_TAB) {
if (this.tabIndex === IMAGE_TABS.UPLOAD_TAB) {
this.submitFile(event);
return;
}
......@@ -108,7 +106,7 @@ export default {
:ok-title="$options.okTitle"
@ok="onOk"
>
<gl-tabs v-if="glFeatures.sseImageUploads" v-model="tabIndex">
<gl-tabs v-model="tabIndex">
<!-- Upload file Tab -->
<gl-tab :title="$options.uploadTabTitle">
<upload-image-tab ref="uploadImageTab" @input="setFile" />
......@@ -128,17 +126,6 @@ export default {
</gl-tab>
</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 -->
<gl-form-group :label="$options.descriptionLabel" label-for="description-input">
<gl-form-input id="description-input" ref="descriptionInput" v-model="description" />
......
......@@ -114,10 +114,9 @@ export default {
if (file) {
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() {
this.$refs.insertVideoModal.show();
......
......@@ -34,6 +34,20 @@ const buildVideoIframe = src => {
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 => {
const { icon, classes, event, command, tooltip, isDivider } = config;
......@@ -59,7 +73,14 @@ export const addCustomEventListener = (editorApi, event, handler) => {
export const removeCustomEventListener = (editorApi, 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) => {
const videoIframe = buildVideoIframe(url);
......
......@@ -16,9 +16,6 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController
prepend_before_action :authenticate_user!, only: [:show]
before_action :assign_ref_and_path, 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
......
---
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
### Images
> - 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}**).
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
#### Upload an image
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
so you can verify the correct image is included and there aren't any references to missing images.
default directory (`source/images/`).
### Videos
......
......@@ -4696,9 +4696,6 @@ msgstr ""
msgid "By %{user_name}"
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}"
msgstr ""
......@@ -16146,6 +16143,9 @@ msgstr ""
msgid "Link title is required"
msgstr ""
msgid "Link to an image"
msgstr ""
msgid "Link to go to GitLab pipeline documentation"
msgstr ""
......@@ -29174,6 +29174,9 @@ msgstr ""
msgid "Upload a private key for your certificate"
msgstr ""
msgid "Upload an image"
msgstr ""
msgid "Upload file"
msgstr ""
......
......@@ -3,9 +3,11 @@ import { mounts, project, branch, baseUrl } from '../../mock_data';
describe('rich_content_editor/renderers/render_image', () => {
let renderer;
let imageRepository;
beforeEach(() => {
renderer = imageRenderer.build(mounts, project, branch, baseUrl);
renderer = imageRenderer.build(mounts, project, branch, baseUrl, imageRepository);
imageRepository = { get: () => null };
});
describe('build', () => {
......@@ -27,6 +29,21 @@ describe('rich_content_editor/renderers/render_image', () => {
});
describe('render', () => {
let skipChildren;
let context;
let node;
beforeEach(() => {
skipChildren = jest.fn();
context = { skipChildren };
node = {
firstChild: {
type: 'img',
literal: 'Some Image',
},
};
});
it.each`
destination | isAbsolute | src
${'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', () => {
${'./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 }) => {
const skipChildren = jest.fn();
const context = { skipChildren };
const node = {
destination,
firstChild: {
type: 'img',
literal: 'Some Image',
},
};
node.destination = destination;
const result = renderer.render(node, context);
expect(result).toEqual({
......@@ -60,5 +70,27 @@ describe('rich_content_editor/renderers/render_image', () => {
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', () => {
});
describe('addImage', () => {
it('calls the exec method on the instance', () => {
const mockImage = { imageUrl: 'some/url.png', description: 'some description' };
const file = new File([], 'some-file.jpg');
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() });
expect(mockInstance.editor.exec).toHaveBeenCalledWith('AddImage', mockImage);
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.insertText).toHaveBeenCalledWith('![some alt text](some/url.png)');
});
});
......
......@@ -15,10 +15,7 @@ describe('Add Image Modal', () => {
const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' });
beforeEach(() => {
wrapper = shallowMount(AddImageModal, {
provide: { glFeatures: { sseImageUploads: true } },
propsData,
});
wrapper = shallowMount(AddImageModal, { propsData });
});
describe('when content is loaded', () => {
......
......@@ -180,7 +180,7 @@ describe('Rich Content Editor', () => {
wrapper.vm.$refs.editor = mockInstance;
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