Commit 09a540d9 authored by Himanshu Kapoor's avatar Himanshu Kapoor

Add support for pasting images in the Web IDE

If you paste a local image in the Web IDE's markdown editor, it gets
uploaded to the same directory with name "image.png". If the file
already exists, a numeric suffix is added to the name.
parent 781b52ec
......@@ -14,6 +14,7 @@ import Editor from '../lib/editor';
import FileTemplatesBar from './file_templates/bar.vue';
import { __ } from '~/locale';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
import { getPathParent, readFileAsDataURL } from '../utils';
export default {
components: {
......@@ -165,6 +166,12 @@ export default {
this.editor = Editor.create(this.editorOptions);
}
this.initEditor();
// listen in capture phase to be able to override Monaco's behaviour.
window.addEventListener('paste', this.onPaste, true);
},
destroyed() {
window.removeEventListener('paste', this.onPaste, true);
},
methods: {
...mapActions([
......@@ -178,6 +185,7 @@ export default {
'updateViewer',
'removePendingTab',
'triggerFilesChange',
'addTempImage',
]),
initEditor() {
if (this.shouldHideEditor && (this.file.content || this.file.raw)) {
......@@ -283,6 +291,29 @@ export default {
this.editor.updateDimensions();
}
},
onPaste(event) {
const editor = this.editor.instance;
const reImage = /^image\/(png|jpg|jpeg|gif)$/;
const file = event.clipboardData.files[0];
if (editor.hasTextFocus() && this.fileType === 'markdown' && reImage.test(file?.type)) {
// don't let the event be passed on to Monaco.
event.preventDefault();
event.stopImmediatePropagation();
return readFileAsDataURL(file).then(content => {
const parentPath = getPathParent(this.file.path);
const path = `${parentPath ? `${parentPath}/` : ''}${file.name}`;
return this.addTempImage({ name: path, rawPath: content }).then(({ name: fileName }) => {
this.editor.replaceSelectedText(`![${fileName}](./${fileName})`);
});
});
}
// do nothing if no image is found in the clipboard
return Promise.resolve();
},
},
viewerTypes,
FILE_VIEW_MODE_EDITOR,
......
import { debounce } from 'lodash';
import { editor as monacoEditor, KeyCode, KeyMod } from 'monaco-editor';
import { editor as monacoEditor, KeyCode, KeyMod, Range } from 'monaco-editor';
import store from '../stores';
import DecorationsController from './decorations/controller';
import DirtyDiffController from './diff/controller';
......@@ -186,6 +186,21 @@ export default class Editor {
});
}
replaceSelectedText(text) {
let selection = this.instance.getSelection();
const range = new Range(
selection.startLineNumber,
selection.startColumn,
selection.endLineNumber,
selection.endColumn,
);
this.instance.executeEdits('', [{ range, text }]);
selection = this.instance.getSelection();
this.instance.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn });
}
get isDiffEditorType() {
return this.instance.getEditorType() === 'vs.editor.IDiffEditor';
}
......
......@@ -30,11 +30,20 @@ export const setResizingStatus = ({ commit }, resizing) => {
export const createTempEntry = (
{ state, commit, dispatch, getters },
{ name, type, content = '', base64 = false, binary = false, rawPath = '' },
{
name,
type,
content = '',
base64 = false,
binary = false,
rawPath = '',
openFile = true,
makeFileActive = true,
},
) => {
const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name;
if (state.entries[name] && !state.entries[name].deleted) {
if (getters.entryExists(name)) {
flash(
sprintf(__('The name "%{name}" is already taken in this directory.'), {
name: name.split('/').pop(),
......@@ -46,7 +55,7 @@ export const createTempEntry = (
true,
);
return;
return undefined;
}
const data = decorateFiles({
......@@ -69,18 +78,32 @@ export const createTempEntry = (
});
if (type === 'blob') {
commit(types.TOGGLE_FILE_OPEN, file.path);
if (openFile) commit(types.TOGGLE_FILE_OPEN, file.path);
commit(types.STAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) });
dispatch('setFileActive', file.path);
if (openFile && makeFileActive) dispatch('setFileActive', file.path);
dispatch('triggerFilesChange');
}
if (parentPath && !state.entries[parentPath].opened) {
commit(types.TOGGLE_TREE_OPEN, parentPath);
}
return file;
};
export const addTempImage = ({ dispatch, getters }, { name, rawPath = '' }) =>
dispatch('createTempEntry', {
name: getters.getAvailableFileName(name),
type: 'blob',
content: rawPath.split('base64,')[1],
base64: true,
binary: true,
rawPath,
openFile: false,
makeFileActive: false,
});
export const scrollToTab = () => {
Vue.nextTick(() => {
const tabs = document.getElementById('tabs');
......
......@@ -161,3 +161,19 @@ export const canCreateMergeRequests = (state, getters) =>
export const canPushCode = (state, getters) =>
Boolean(getters.findProjectPermissions(state.currentProjectId)[PERMISSION_PUSH_CODE]);
export const entryExists = state => path =>
Boolean(state.entries[path] && !state.entries[path].deleted);
export const getAvailableFileName = (state, getters) => path => {
let newPath = path;
while (getters.entryExists(newPath)) {
newPath = newPath.replace(
/([ _-]?)(\d*)(\..+?$|$)/,
(_, before, number, after) => `${before || '_'}${Number(number) + 1}${after}`,
);
}
return newPath;
};
......@@ -85,16 +85,37 @@ export function insertFinalNewline(content, eol = '\n') {
return content.slice(-eol.length) !== eol ? `${content}${eol}` : content;
}
export function getPathParents(path) {
export function getPathParents(path, maxDepth = Infinity) {
const pathComponents = path.split('/');
const paths = [];
while (pathComponents.length) {
let depth = 0;
while (pathComponents.length && depth < maxDepth) {
pathComponents.pop();
let parentPath = pathComponents.join('/');
if (parentPath.startsWith('/')) parentPath = parentPath.slice(1);
if (parentPath) paths.push(parentPath);
depth += 1;
}
return paths;
}
export function getPathParent(path) {
return getPathParents(path, 1)[0];
}
/**
* Takes a file object and returns a data uri of its contents.
*
* @param {File} file
*/
export function readFileAsDataURL(file) {
return new Promise(resolve => {
const reader = new FileReader();
reader.addEventListener('load', e => resolve(e.target.result), { once: true });
reader.readAsDataURL(file);
});
}
---
title: Add support for pasting images in the Web IDE
merge_request: 33256
author:
type: added
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import '~/behaviors/markdown/render_gfm';
import { Range } from 'monaco-editor';
import axios from '~/lib/utils/axios_utils';
import store from '~/ide/stores';
import { createStore } from '~/ide/stores';
import repoEditor from '~/ide/components/repo_editor.vue';
import Editor from '~/ide/lib/editor';
import { leftSidebarViews, FILE_VIEW_MODE_EDITOR, FILE_VIEW_MODE_PREVIEW } from '~/ide/constants';
import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { file, resetStore } from '../helpers';
import { file } from '../helpers';
describe('RepoEditor', () => {
let vm;
let store;
beforeEach(() => {
const f = {
......@@ -20,6 +22,7 @@ describe('RepoEditor', () => {
};
const RepoEditor = Vue.extend(repoEditor);
store = createStore();
vm = createComponentWithStore(RepoEditor, store, {
file: f,
});
......@@ -56,8 +59,6 @@ describe('RepoEditor', () => {
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
Editor.editorInstance.dispose();
});
......@@ -500,4 +501,80 @@ describe('RepoEditor', () => {
.catch(done.fail);
});
});
describe('onPaste', () => {
const setFileName = name => {
Vue.set(vm, 'file', {
...vm.file,
content: 'hello world\n',
name,
path: `foo/${name}`,
key: 'new',
});
vm.$store.state.entries[vm.file.path] = vm.file;
};
const pasteImage = () => {
window.dispatchEvent(
Object.assign(new Event('paste'), {
clipboardData: {
files: [new File(['foo'], 'foo.png', { type: 'image/png' })],
},
}),
);
};
beforeEach(() => {
setFileName('bar.md');
vm.$store.state.trees['gitlab-org/gitlab'] = { tree: [] };
vm.$store.state.currentProjectId = 'gitlab-org';
vm.$store.state.currentBranchId = 'gitlab';
// create a new model each time, otherwise tests conflict with each other
// because of same model being used in multiple tests
Editor.editorInstance.modelManager.dispose();
vm.setupEditor();
return waitForPromises().then(() => {
// set cursor to line 2, column 1
vm.editor.instance.setSelection(new Range(2, 1, 2, 1));
vm.editor.instance.focus();
});
});
it('adds an image entry to the same folder for a pasted image in a markdown file', () => {
pasteImage();
return waitForPromises().then(() => {
expect(vm.$store.state.entries['foo/foo.png']).toMatchObject({
path: 'foo/foo.png',
type: 'blob',
content: 'Zm9v',
base64: true,
binary: true,
rawPath: 'data:image/png;base64,Zm9v',
});
});
});
it("adds a markdown image tag to the file's contents", () => {
pasteImage();
return waitForPromises().then(() => {
expect(vm.file.content).toBe('hello world\n![foo.png](./foo.png)');
});
});
it("does not add file to state or set markdown image syntax if the file isn't markdown", () => {
setFileName('myfile.txt');
pasteImage();
return waitForPromises().then(() => {
expect(vm.$store.state.entries['foo/foo.png']).toBeUndefined();
expect(vm.file.content).toBe('hello world\n');
});
});
});
});
import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor';
import {
editor as monacoEditor,
languages as monacoLanguages,
Range,
Selection,
} from 'monaco-editor';
import Editor from '~/ide/lib/editor';
import { defaultEditorOptions } from '~/ide/lib/editor_options';
import { file } from '../helpers';
......@@ -194,6 +199,38 @@ describe('Multi-file editor library', () => {
});
});
describe('replaceSelectedText', () => {
let model;
let editor;
beforeEach(() => {
instance.createInstance(holder);
model = instance.createModel({
...file(),
key: 'index.md',
path: 'index.md',
});
instance.attachModel(model);
editor = instance.instance;
editor.getModel().setValue('foo bar baz');
editor.setSelection(new Range(1, 5, 1, 8));
instance.replaceSelectedText('hello');
});
it('replaces the text selected in editor with the one provided', () => {
expect(editor.getModel().getValue()).toBe('foo hello baz');
});
it('sets cursor to end of the replaced string', () => {
const selection = editor.getSelection();
expect(selection).toEqual(new Selection(1, 10, 1, 10));
});
});
describe('dispose', () => {
it('calls disposble dispose method', () => {
jest.spyOn(instance.disposable, 'dispose');
......
......@@ -417,4 +417,69 @@ describe('IDE store getters', () => {
expect(localStore.getters[getterName]).toBe(val);
});
});
describe('entryExists', () => {
beforeEach(() => {
localState.entries = {
foo: file('foo', 'foo', 'tree'),
'foo/bar.png': file(),
};
});
it.each`
path | deleted | value
${'foo/bar.png'} | ${false} | ${true}
${'foo/bar.png'} | ${true} | ${false}
${'foo'} | ${false} | ${true}
`(
'returns $value for an existing entry path: $path (deleted: $deleted)',
({ path, deleted, value }) => {
localState.entries[path].deleted = deleted;
expect(localStore.getters.entryExists(path)).toBe(value);
},
);
it('returns false for a non existing entry path', () => {
expect(localStore.getters.entryExists('bar.baz')).toBe(false);
});
});
describe('getAvailableFileName', () => {
it.each`
path | newPath
${'foo'} | ${'foo_1'}
${'foo__93.png'} | ${'foo__94.png'}
${'foo/bar.png'} | ${'foo/bar_1.png'}
${'foo/bar--34.png'} | ${'foo/bar--35.png'}
${'foo/bar 2.png'} | ${'foo/bar 3.png'}
${'foo/bar-621.png'} | ${'foo/bar-622.png'}
${'jquery.min.js'} | ${'jquery_1.min.js'}
${'my_spec_22.js.snap'} | ${'my_spec_23.js.snap'}
${'subtitles5.mp4.srt'} | ${'subtitles_6.mp4.srt'}
${'sample_file.mp3'} | ${'sample_file_1.mp3'}
${'Screenshot 2020-05-26 at 10.53.08 PM.png'} | ${'Screenshot 2020-05-26 at 11.53.08 PM.png'}
`('suffixes the path with a number if the path already exists', ({ path, newPath }) => {
localState.entries[path] = file();
expect(localStore.getters.getAvailableFileName(path)).toBe(newPath);
});
it('loops through all incremented entries and keeps trying until a file path that does not exist is found', () => {
localState.entries = {
'bar/baz_1.png': file(),
'bar/baz_2.png': file(),
'bar/baz_3.png': file(),
'bar/baz_4.png': file(),
'bar/baz_5.png': file(),
'bar/baz_72.png': file(),
};
expect(localStore.getters.getAvailableFileName('bar/baz_1.png')).toBe('bar/baz_6.png');
});
it('returns the entry path as is if the path does not exist', () => {
expect(localStore.getters.getAvailableFileName('foo-bar1.jpg')).toBe('foo-bar1.jpg');
});
});
});
......@@ -5,6 +5,8 @@ import {
insertFinalNewline,
trimTrailingWhitespace,
getPathParents,
getPathParent,
readFileAsDataURL,
} from '~/ide/utils';
import { languages } from 'monaco-editor';
......@@ -203,5 +205,38 @@ describe('WebIDE utils', () => {
`('gets all parent directory names for path: $path', ({ path, parents }) => {
expect(getPathParents(path)).toEqual(parents);
});
it.each`
path | depth | parents
${'foo/bar/baz/index.md'} | ${0} | ${[]}
${'foo/bar/baz/index.md'} | ${1} | ${['foo/bar/baz']}
${'foo/bar/baz/index.md'} | ${2} | ${['foo/bar/baz', 'foo/bar']}
${'foo/bar/baz/index.md'} | ${3} | ${['foo/bar/baz', 'foo/bar', 'foo']}
${'foo/bar/baz/index.md'} | ${4} | ${['foo/bar/baz', 'foo/bar', 'foo']}
`('gets only the immediate $depth parents if when depth=$depth', ({ path, depth, parents }) => {
expect(getPathParents(path, depth)).toEqual(parents);
});
});
describe('getPathParent', () => {
it.each`
path | parents
${'foo/bar/baz/index.md'} | ${'foo/bar/baz'}
${'foo/bar/baz'} | ${'foo/bar'}
${'index.md'} | ${undefined}
${'path with/spaces to/something.md'} | ${'path with/spaces to'}
`('gets the immediate parent for path: $path', ({ path, parents }) => {
expect(getPathParent(path)).toEqual(parents);
});
});
describe('readFileAsDataURL', () => {
it('reads a file and returns its output as a data url', () => {
const file = new File(['foo'], 'foo.png', { type: 'image/png' });
return readFileAsDataURL(file).then(contents => {
expect(contents).toBe('data:image/png;base64,Zm9v');
});
});
});
});
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