Skip to content
This repository was archived by the owner on Jun 26, 2020. It is now read-only.

Commit fec452d

Browse files
authored
Merge pull request #47 from ckeditor/t/42
Fix: Fixed two issues related to dropping images. First, when dropping a file into an empty paragraph, that paragraph should be replaced with that image. Second, drop position should be read correctly when the editor is focused upon drop. Closes #42. Closes #29. BREAKING CHANGE: `UploadImageCommand` doesn't optimize the drop position itself anymore. Instead, a separate `findOptimalInsertionPosition()` function was introduced. BREAKING CHANGE: `UploadImageCommand` doesn't verify the type of file anymore. This needs to be done by the caller.
2 parents ab81012 + 3fd864f commit fec452d

File tree

8 files changed

+362
-157
lines changed

8 files changed

+362
-157
lines changed

src/imageuploadbutton.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
1111
import ImageUploadEngine from './imageuploadengine';
1212
import FileDialogButtonView from './ui/filedialogbuttonview';
1313
import imageIcon from '@ckeditor/ckeditor5-core/theme/icons/image.svg';
14+
import { isImageType, findOptimalInsertionPosition } from './utils';
1415

1516
/**
1617
* Image upload button plugin.
@@ -50,7 +51,11 @@ export default class ImageUploadButton extends Plugin {
5051

5152
view.on( 'done', ( evt, files ) => {
5253
for ( const file of files ) {
53-
editor.execute( 'imageUpload', { file } );
54+
const insertAt = findOptimalInsertionPosition( editor.document.selection );
55+
56+
if ( isImageType( file ) ) {
57+
editor.execute( 'imageUpload', { file, insertAt } );
58+
}
5459
}
5560
} );
5661

src/imageuploadcommand.js

Lines changed: 17 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,10 @@
33
* For licensing, see LICENSE.md.
44
*/
55

6-
import ModelDocumentFragment from '@ckeditor/ckeditor5-engine/src/model/documentfragment';
76
import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
87
import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range';
9-
import ModelPosition from '@ckeditor/ckeditor5-engine/src/model/position';
108
import ModelSelection from '@ckeditor/ckeditor5-engine/src/model/selection';
119
import FileRepository from './filerepository';
12-
import { isImageType } from './utils';
1310
import Command from '@ckeditor/ckeditor5-core/src/command';
1411

1512
/**
@@ -29,7 +26,9 @@ export default class ImageUploadCommand extends Command {
2926
* @param {Object} options Options for executed command.
3027
* @param {File} options.file Image file to upload.
3128
* @param {module:engine/model/position~Position} [options.insertAt] Position at which the image should be inserted.
32-
* If the position won't be specified the image will be inserted next to the selection.
29+
* If the position is not specified the image will be inserted into the current selection.
30+
* Note: You can use the {@link module:upload/utils~findOptimalInsertionPosition} function to calculate
31+
* (e.g. based on the current selection) a position which is more optimal from UX perspective.
3332
* @param {module:engine/model/batch~Batch} [options.batch] Batch to collect all the change steps.
3433
* New batch will be created if this option is not set.
3534
*/
@@ -41,28 +40,25 @@ export default class ImageUploadCommand extends Command {
4140
const selection = doc.selection;
4241
const fileRepository = editor.plugins.get( FileRepository );
4342

44-
if ( !isImageType( file ) ) {
45-
return;
46-
}
47-
4843
doc.enqueueChanges( () => {
49-
const insertAt = options.insertAt || getInsertionPosition( doc );
50-
51-
// No position to insert.
52-
if ( !insertAt ) {
53-
return;
54-
}
55-
5644
const imageElement = new ModelElement( 'image', {
5745
uploadId: fileRepository.createLoader( file ).id
5846
} );
59-
const documentFragment = new ModelDocumentFragment( [ imageElement ] );
60-
const range = new ModelRange( insertAt );
61-
const insertSelection = new ModelSelection();
6247

63-
insertSelection.setRanges( [ range ] );
64-
editor.data.insertContent( documentFragment, insertSelection, batch );
65-
selection.setRanges( [ ModelRange.createOn( imageElement ) ] );
48+
let insertAtSelection;
49+
50+
if ( options.insertAt ) {
51+
insertAtSelection = new ModelSelection( [ new ModelRange( options.insertAt ) ] );
52+
} else {
53+
insertAtSelection = doc.selection;
54+
}
55+
56+
editor.data.insertContent( imageElement, insertAtSelection, batch );
57+
58+
// Inserting an image might've failed due to schema regulations.
59+
if ( imageElement.parent ) {
60+
selection.setRanges( [ ModelRange.createOn( imageElement ) ] );
61+
}
6662
} );
6763
}
6864
}
@@ -71,26 +67,4 @@ export default class ImageUploadCommand extends Command {
7167
//
7268
// @param {module:engine/model/document~Document} doc
7369
// @returns {module:engine/model/position~Position|undefined}
74-
function getInsertionPosition( doc ) {
75-
const selection = doc.selection;
76-
const selectedElement = selection.getSelectedElement();
77-
78-
// If selected element is placed directly in root - return position after that element.
79-
if ( selectedElement && selectedElement.parent.is( 'rootElement' ) ) {
80-
return ModelPosition.createAfter( selectedElement );
81-
}
82-
83-
const firstBlock = doc.selection.getSelectedBlocks().next().value;
8470

85-
if ( firstBlock ) {
86-
const positionAfter = ModelPosition.createAfter( firstBlock );
87-
88-
// If selection is at the end of the block - return position after the block.
89-
if ( selection.focus.isTouching( positionAfter ) ) {
90-
return positionAfter;
91-
}
92-
93-
// Otherwise return position before the block.
94-
return ModelPosition.createBefore( firstBlock );
95-
}
96-
}

src/imageuploadengine.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
1111
import FileRepository from './filerepository';
1212
import ImageUploadCommand from './imageuploadcommand';
1313
import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
14-
import { isImageType } from './utils';
14+
import ModelSelection from '@ckeditor/ckeditor5-engine/src/model/selection';
15+
import { isImageType, findOptimalInsertionPosition } from './utils';
1516

1617
/**
1718
* Image upload engine plugin.
@@ -45,11 +46,21 @@ export default class ImageUploadEngine extends Plugin {
4546

4647
// Execute imageUpload command when image is dropped or pasted.
4748
editor.editing.view.on( 'clipboardInput', ( evt, data ) => {
49+
let targetModelSelection = new ModelSelection(
50+
data.targetRanges.map( viewRange => editor.editing.mapper.toModelRange( viewRange ) )
51+
);
52+
4853
for ( const file of data.dataTransfer.files ) {
54+
const insertAt = findOptimalInsertionPosition( targetModelSelection );
55+
4956
if ( isImageType( file ) ) {
50-
editor.execute( 'imageUpload', { file } );
57+
editor.execute( 'imageUpload', { file, insertAt } );
5158
evt.stop();
5259
}
60+
61+
// Use target ranges only for the first image. Then, use that image position
62+
// so we keep adding the next ones after the previous one.
63+
targetModelSelection = doc.selection;
5364
}
5465
} );
5566

src/utils.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
* @module upload/utils
88
*/
99

10+
import ModelPosition from '@ckeditor/ckeditor5-engine/src/model/position';
11+
1012
/**
1113
* Checks if given file is an image.
1214
*
@@ -19,3 +21,47 @@ export function isImageType( file ) {
1921
return types.test( file.type );
2022
}
2123

24+
/**
25+
* Returns a model position which is optimal (in terms of UX) for inserting an image.
26+
*
27+
* For instance, if a selection is in a middle of a paragraph, position before this paragraph
28+
* will be returned, so that it's not split. If the selection is at the end of a paragraph,
29+
* position after this paragraph will be returned.
30+
*
31+
* Note: If selection is placed in an empty block, that block will be returned. If that position
32+
* is then passed to {@link module:engine/controller/datacontroller~DataController#insertContent}
33+
* that block will be fully replaced by the image.
34+
*
35+
* @param {module:engine/model/selection~Selection} selection Selection based on which the
36+
* insertion position should be calculated.
37+
* @returns {module:engine/model/position~Position} The optimal position.
38+
*/
39+
export function findOptimalInsertionPosition( selection ) {
40+
const selectedElement = selection.getSelectedElement();
41+
42+
if ( selectedElement ) {
43+
return ModelPosition.createAfter( selectedElement );
44+
}
45+
46+
const firstBlock = selection.getSelectedBlocks().next().value;
47+
48+
if ( firstBlock ) {
49+
// If inserting into an empty block – return position in that block. It will get
50+
// replaced with the image by insertContent(). #42.
51+
if ( firstBlock.isEmpty ) {
52+
return ModelPosition.createAt( firstBlock );
53+
}
54+
55+
const positionAfter = ModelPosition.createAfter( firstBlock );
56+
57+
// If selection is at the end of the block - return position after the block.
58+
if ( selection.focus.isTouching( positionAfter ) ) {
59+
return positionAfter;
60+
}
61+
62+
// Otherwise return position before the block.
63+
return ModelPosition.createBefore( firstBlock );
64+
}
65+
66+
return selection.focus;
67+
}

tests/imageuploadbutton.js

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,50 @@
66
/* globals document */
77

88
import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
9+
910
import Image from '@ckeditor/ckeditor5-image/src/image';
1011
import FileDialogButtonView from '../src/ui/filedialogbuttonview';
12+
import FileRepository from '../src/filerepository';
1113
import ImageUploadButton from '../src/imageuploadbutton';
1214
import ImageUploadEngine from '../src/imageuploadengine';
13-
import { createNativeFileMock } from './_utils/mocks';
15+
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
16+
import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
17+
18+
import { createNativeFileMock, AdapterMock } from './_utils/mocks';
19+
import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
1420

1521
describe( 'ImageUploadButton', () => {
16-
let editor;
22+
let editor, doc, editorElement, fileRepository;
1723

1824
beforeEach( () => {
19-
const editorElement = document.createElement( 'div' );
25+
editorElement = document.createElement( 'div' );
2026
document.body.appendChild( editorElement );
2127

22-
return ClassicEditor.create( editorElement, {
23-
plugins: [ Image, ImageUploadButton ]
24-
} )
28+
return ClassicEditor
29+
.create( editorElement, {
30+
plugins: [ Paragraph, Image, ImageUploadButton, FileRepository ]
31+
} )
2532
.then( newEditor => {
2633
editor = newEditor;
34+
doc = editor.document;
35+
36+
fileRepository = editor.plugins.get( FileRepository );
37+
fileRepository.createAdapter = loader => {
38+
return new AdapterMock( loader );
39+
};
40+
41+
// Hide all notifications (prevent alert() calls).
42+
const notification = editor.plugins.get( Notification );
43+
notification.on( 'show', evt => evt.stop() );
2744
} );
2845
} );
2946

47+
afterEach( () => {
48+
editorElement.remove();
49+
50+
return editor.destroy();
51+
} );
52+
3053
it( 'should include ImageUploadEngine', () => {
3154
expect( editor.plugins.get( ImageUploadEngine ) ).to.be.instanceOf( ImageUploadEngine );
3255
} );
@@ -60,5 +83,52 @@ describe( 'ImageUploadButton', () => {
6083
expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
6184
expect( executeStub.firstCall.args[ 1 ].file ).to.equal( files[ 0 ] );
6285
} );
86+
87+
it( 'should optimize the insertion position', () => {
88+
const button = editor.ui.componentFactory.create( 'insertImage' );
89+
const files = [ createNativeFileMock() ];
90+
91+
setModelData( doc, '<paragraph>f[]oo</paragraph>' );
92+
93+
button.fire( 'done', files );
94+
95+
const id = fileRepository.getLoader( files[ 0 ] ).id;
96+
97+
expect( getModelData( doc ) ).to.equal(
98+
`[<image uploadId="${ id }" uploadStatus="reading"></image>]` +
99+
'<paragraph>foo</paragraph>'
100+
);
101+
} );
102+
103+
it( 'should correctly insert multiple files', () => {
104+
const button = editor.ui.componentFactory.create( 'insertImage' );
105+
const files = [ createNativeFileMock(), createNativeFileMock() ];
106+
107+
setModelData( doc, '<paragraph>foo[]</paragraph><paragraph>bar</paragraph>' );
108+
109+
button.fire( 'done', files );
110+
111+
const id1 = fileRepository.getLoader( files[ 0 ] ).id;
112+
const id2 = fileRepository.getLoader( files[ 1 ] ).id;
113+
114+
expect( getModelData( doc ) ).to.equal(
115+
'<paragraph>foo</paragraph>' +
116+
`<image uploadId="${ id1 }" uploadStatus="reading"></image>` +
117+
`[<image uploadId="${ id2 }" uploadStatus="reading"></image>]` +
118+
'<paragraph>bar</paragraph>'
119+
);
120+
} );
121+
122+
it( 'should not execute imageUpload if the file is not an image', () => {
123+
const executeStub = sinon.stub( editor, 'execute' );
124+
const button = editor.ui.componentFactory.create( 'insertImage' );
125+
const file = {
126+
type: 'media/mp3',
127+
size: 1024
128+
};
129+
130+
button.fire( 'done', [ file ] );
131+
sinon.assert.notCalled( executeStub );
132+
} );
63133
} );
64134

0 commit comments

Comments
 (0)