Commit e265b006 authored by Himanshu Kapoor's avatar Himanshu Kapoor

Allow WebIDE markdown to preview uncommited images

Web IDE now post-processes markdown content and replaces any uncommitted
image paths with base64 encoded URIs of those files present locally in
the IDE state.
parent 9806da56
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
import Editor from '../lib/editor'; import Editor from '../lib/editor';
import FileTemplatesBar from './file_templates/bar.vue'; import FileTemplatesBar from './file_templates/bar.vue';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
export default { export default {
components: { components: {
...@@ -26,6 +27,12 @@ export default { ...@@ -26,6 +27,12 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
content: '',
images: {},
};
},
computed: { computed: {
...mapState('rightPane', { ...mapState('rightPane', {
rightPaneIsOpen: 'isOpen', rightPaneIsOpen: 'isOpen',
...@@ -36,6 +43,7 @@ export default { ...@@ -36,6 +43,7 @@ export default {
'currentActivityView', 'currentActivityView',
'renderWhitespaceInCode', 'renderWhitespaceInCode',
'editorTheme', 'editorTheme',
'entries',
]), ]),
...mapGetters([ ...mapGetters([
'currentMergeRequest', 'currentMergeRequest',
...@@ -136,6 +144,18 @@ export default { ...@@ -136,6 +144,18 @@ export default {
this.$nextTick(() => this.refreshEditorDimensions()); this.$nextTick(() => this.refreshEditorDimensions());
} }
}, },
showContentViewer(val) {
if (!val) return;
if (this.fileType === 'markdown') {
const { content, images } = extractMarkdownImagesFromEntries(this.file, this.entries);
this.content = content;
this.images = images;
} else {
this.content = this.file.content || this.file.raw;
this.images = {};
}
},
}, },
beforeDestroy() { beforeDestroy() {
this.editor.dispose(); this.editor.dispose();
...@@ -310,7 +330,8 @@ export default { ...@@ -310,7 +330,8 @@ export default {
></div> ></div>
<content-viewer <content-viewer
v-if="showContentViewer" v-if="showContentViewer"
:content="file.content || file.raw" :content="content"
:images="images"
:path="file.rawPath || file.path" :path="file.rawPath || file.path"
:file-path="file.path" :file-path="file.path"
:file-size="file.size" :file-size="file.size"
......
import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants'; import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants';
import { relativePathToAbsolute, isAbsolute, isRootRelative } from '~/lib/utils/url_utility';
export const dataStructure = () => ({ export const dataStructure = () => ({
id: '', id: '',
...@@ -274,3 +275,45 @@ export const pathsAreEqual = (a, b) => { ...@@ -274,3 +275,45 @@ export const pathsAreEqual = (a, b) => {
// if the contents of a file dont end with a newline, this function adds a newline // if the contents of a file dont end with a newline, this function adds a newline
export const addFinalNewlineIfNeeded = content => export const addFinalNewlineIfNeeded = content =>
content.charAt(content.length - 1) !== '\n' ? `${content}\n` : content; content.charAt(content.length - 1) !== '\n' ? `${content}\n` : content;
export function extractMarkdownImagesFromEntries(mdFile, entries) {
/**
* Regex to identify an image tag in markdown, like:
*
* ![img alt goes here](/img.png)
* ![img alt](../img 1/img.png "my image title")
* ![img alt](https://gitlab.com/assets/logo.svg "title here")
*
*/
const reMdImage = /!\[([^\]]*)\]\((.*?)(?:(?="|\))"([^"]*)")?\)/gi;
const prefix = 'gl_md_img_';
const images = {};
let content = mdFile.content || mdFile.raw;
let i = 0;
content = content.replace(reMdImage, (_, alt, path, title) => {
const imagePath = (isRootRelative(path) ? path : relativePathToAbsolute(path, mdFile.path))
.substr(1)
.trim();
const imageContent = entries[imagePath]?.content || entries[imagePath]?.raw;
if (!isAbsolute(path) && imageContent) {
const ext = path.includes('.')
? path
.split('.')
.pop()
.trim()
: 'jpeg';
const src = `data:image/${ext};base64,${imageContent}`;
i += 1;
const key = `{{${prefix}${i}}}`;
images[key] = { alt, src, title };
return key;
}
return title ? `![${alt}](${path}"${title}")` : `![${alt}](${path})`;
});
return { content, images };
}
...@@ -39,6 +39,11 @@ export default { ...@@ -39,6 +39,11 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
images: {
type: Object,
required: false,
default: () => ({}),
},
}, },
computed: { computed: {
viewer() { viewer() {
...@@ -67,6 +72,7 @@ export default { ...@@ -67,6 +72,7 @@ export default {
:file-size="fileSize" :file-size="fileSize"
:project-path="projectPath" :project-path="projectPath"
:content="content" :content="content"
:images="images"
:commit-sha="commitSha" :commit-sha="commitSha"
/> />
</div> </div>
......
...@@ -5,6 +5,7 @@ import '~/behaviors/markdown/render_gfm'; ...@@ -5,6 +5,7 @@ import '~/behaviors/markdown/render_gfm';
import { GlSkeletonLoading } from '@gitlab/ui'; import { GlSkeletonLoading } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { forEach, escape } from 'lodash';
const { CancelToken } = axios; const { CancelToken } = axios;
let axiosSource; let axiosSource;
...@@ -32,6 +33,11 @@ export default { ...@@ -32,6 +33,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
images: {
type: Object,
required: false,
default: () => ({}),
},
}, },
data() { data() {
return { return {
...@@ -76,7 +82,15 @@ export default { ...@@ -76,7 +82,15 @@ export default {
postOptions, postOptions,
) )
.then(({ data }) => { .then(({ data }) => {
this.previewContent = data.body; let previewContent = data.body;
forEach(this.images, ({ src, title = '', alt }, key) => {
previewContent = previewContent.replace(
key,
`<img src="${escape(src)}" title="${escape(title)}" alt="${escape(alt)}">`,
);
});
this.previewContent = previewContent;
this.isLoading = false; this.isLoading = false;
this.$nextTick(() => { this.$nextTick(() => {
......
---
title: Allow Web IDE markdown to preview uncommitted images
merge_request: 31540
author:
type: added
...@@ -685,4 +685,75 @@ describe('Multi-file store utils', () => { ...@@ -685,4 +685,75 @@ describe('Multi-file store utils', () => {
}); });
}); });
}); });
describe('extractMarkdownImagesFromEntries', () => {
let mdFile;
let entries;
beforeEach(() => {
const img = { content: '/base64/encoded/image+' };
mdFile = { path: 'path/to/some/directory/myfile.md' };
entries = {
// invalid (or lack of) extensions are also supported as long as there's
// a real image inside and can go into an <img> tag's `src` and the browser
// can render it
img,
'img.js': img,
'img.png': img,
'img.with.many.dots.png': img,
'path/to/img.gif': img,
'path/to/some/img.jpg': img,
'path/to/some/img 1/img.png': img,
'path/to/some/directory/img.png': img,
'path/to/some/directory/img 1.png': img,
};
});
it.each`
markdownBefore | ext | imgAlt | imgTitle
${'* ![img](/img)'} | ${'jpeg'} | ${'img'} | ${undefined}
${'* ![img](/img.js)'} | ${'js'} | ${'img'} | ${undefined}
${'* ![img](img.png)'} | ${'png'} | ${'img'} | ${undefined}
${'* ![img](./img.png)'} | ${'png'} | ${'img'} | ${undefined}
${'* ![with spaces](../img 1/img.png)'} | ${'png'} | ${'with spaces'} | ${undefined}
${'* ![img](../../img.gif " title ")'} | ${'gif'} | ${'img'} | ${' title '}
${'* ![img](../img.jpg)'} | ${'jpg'} | ${'img'} | ${undefined}
${'* ![img](/img.png "title")'} | ${'png'} | ${'img'} | ${'title'}
${'* ![img](/img.with.many.dots.png)'} | ${'png'} | ${'img'} | ${undefined}
${'* ![img](img 1.png)'} | ${'png'} | ${'img'} | ${undefined}
${'* ![img](img.png "title here")'} | ${'png'} | ${'img'} | ${'title here'}
`(
'correctly transforms markdown with uncommitted images: $markdownBefore',
({ markdownBefore, ext, imgAlt, imgTitle }) => {
mdFile.content = markdownBefore;
expect(utils.extractMarkdownImagesFromEntries(mdFile, entries)).toEqual({
content: '* {{gl_md_img_1}}',
images: {
'{{gl_md_img_1}}': {
src: `data:image/${ext};base64,/base64/encoded/image+`,
alt: imgAlt,
title: imgTitle,
},
},
});
},
);
it.each`
markdown
${'* ![img](i.png)'}
${'* ![img](img.png invalid title)'}
${'* ![img](img.png "incorrect" "markdown")'}
${'* ![img](https://gitlab.com/logo.png)'}
${'* ![img](https://gitlab.com/some/deep/nested/path/logo.png)'}
`("doesn't touch invalid or non-existant images in markdown: $markdown", ({ markdown }) => {
mdFile.content = markdown;
expect(utils.extractMarkdownImagesFromEntries(mdFile, entries)).toEqual({
content: markdown,
images: {},
});
});
});
}); });
...@@ -35,7 +35,7 @@ describe('MarkdownViewer', () => { ...@@ -35,7 +35,7 @@ describe('MarkdownViewer', () => {
describe('success', () => { describe('success', () => {
beforeEach(() => { beforeEach(() => {
mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).replyOnce(200, { mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).replyOnce(200, {
body: '<b>testing</b>', body: '<b>testing</b> {{gl_md_img_1}}',
}); });
}); });
...@@ -53,15 +53,48 @@ describe('MarkdownViewer', () => { ...@@ -53,15 +53,48 @@ describe('MarkdownViewer', () => {
}); });
}); });
it('receives the filePath as a parameter and passes it on to the server', () => { it('receives the filePath and commitSha as a parameters and passes them on to the server', () => {
createComponent({ filePath: 'foo/test.md' }); createComponent({ filePath: 'foo/test.md', commitSha: 'abcdef' });
expect(axios.post).toHaveBeenCalledWith( expect(axios.post).toHaveBeenCalledWith(
`${gon.relative_url_root}/testproject/preview_markdown`, `${gon.relative_url_root}/testproject/preview_markdown`,
{ path: 'foo/test.md', text: '* Test' }, { path: 'foo/test.md', text: '* Test', ref: 'abcdef' },
expect.any(Object), expect.any(Object),
); );
}); });
it.each`
imgSrc | imgAlt
${'data:image/jpeg;base64,AAAAAA+/'} | ${'my image title'}
${'data:image/jpeg;base64,AAAAAA+/'} | ${'"somebody\'s image" &'}
${'hack" onclick=alert(0)'} | ${'hack" onclick=alert(0)'}
${'hack\\" onclick=alert(0)'} | ${'hack\\" onclick=alert(0)'}
${"hack' onclick=alert(0)"} | ${"hack' onclick=alert(0)"}
${"hack'><script>alert(0)</script>"} | ${"hack'><script>alert(0)</script>"}
`(
'transforms template tags with base64 encoded images available locally',
({ imgSrc, imgAlt }) => {
createComponent({
images: {
'{{gl_md_img_1}}': {
src: imgSrc,
alt: imgAlt,
title: imgAlt,
},
},
});
return waitForPromises().then(() => {
const img = wrapper.find('.md-previewer img').element;
// if the values are the same as the input, it means
// they were escaped correctly
expect(img).toHaveAttr('src', imgSrc);
expect(img).toHaveAttr('alt', imgAlt);
expect(img).toHaveAttr('title', imgAlt);
});
},
);
}); });
describe('error', () => { describe('error', () => {
......
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