8
0
Просмотр исходного кода

Added ImageEngine feature and tests.

Szymon Kupś 9 лет назад
Родитель
Сommit
0a317f4e37

+ 155 - 0
packages/ckeditor5-image/src/image.js

@@ -0,0 +1,155 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Feature from '../core/feature.js';
+import ImageEngine from './imageengine.js';
+import ViewRange from '../engine/view/range.js';
+import ModelLiveRange from '../engine/model/liverange.js';
+import ModelRange from '../engine/model/range.js';
+import ViewPosition from '../engine/view/position.js';
+import ViewSelection from '../engine/view/selection.js';
+import MouseDownObserver from './mousedownobserver.js';
+
+export default class Image extends Feature {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ImageEngine ];
+	}
+
+	init() {
+		const editor = this.editor;
+		const modelDocument = editor.document;
+		const viewDocument = editor.editing.view;
+		const editingPipeline = editor.editing;
+		const selected = new Set();
+
+		// Prevent selection change on mousedown.
+		viewDocument.addObserver( MouseDownObserver );
+		viewDocument.on( 'mousedown', ( eventInfo, domEventData ) => {
+			const target = domEventData.target;
+
+			if ( isImageWidget( target ) ) {
+				domEventData.preventDefault();
+
+				if ( !viewDocument.isFocused ) {
+					viewDocument.focus();
+				}
+
+				const viewRange = ViewRange.createOn( target );
+				const modelRange = ModelLiveRange.createFromRange( editingPipeline.mapper.toModelRange( viewRange ) );
+
+				modelDocument.enqueueChanges( ( ) => {
+					modelDocument.selection.setRanges( [ modelRange ] );
+				} );
+			}
+		} );
+
+		// Handle selection placing inside figure - convert it to selecting whole image widget.
+		viewDocument.on( 'selectionChange', ( eventInfo, data ) => {
+			const newSelection = data.newSelection;
+
+			if ( newSelection.rangeCount && newSelection.getFirstPosition().parent.name == 'figure' ) {
+				eventInfo.stop();
+
+				const viewRange = ViewRange.createOn( newSelection.getFirstPosition().parent );
+				const modelRange = ModelLiveRange.createFromRange( editingPipeline.mapper.toModelRange( viewRange ) );
+
+				modelDocument.enqueueChanges( ( ) => {
+					modelDocument.selection.setRanges( [ modelRange ] );
+				} );
+			}
+		}, { priority: 'high' } );
+
+		viewDocument.on( 'keydown', ( eventInfo, data ) => {
+			const viewSelection = viewDocument.selection;
+
+			if ( viewSelection.isFake && !viewSelection.isCollapsed && viewSelection.rangeCount == 1 ) {
+				const nodeAfter = viewSelection.anchor.nodeAfter;
+				const nodeBefore = viewSelection.focus.nodeBefore;
+
+				if ( nodeAfter === nodeBefore && isImageWidget( nodeAfter ) ) {
+					// Stop to prevent default fake selection handling.
+					eventInfo.stop();
+
+					// Prevent selection change.
+					data.preventDefault();
+
+					if ( data.keyCode == 39 || data.keyCode == 40 ) {
+						// TODO: Check if sibling exists.
+						// const modelElement = editingPipeline.mapper.toModelElement( nodeAfter.nextSibling );
+						// const modelPosition = ModelPosition.createAt( modelElement );
+						// const modelRange = new ModelLiveRange( modelPosition );
+						//
+						// modelDocument.enqueueChanges( () => {
+						// 	modelDocument.selection.setRanges( [ modelRange ] );
+						// } );
+						const viewPosition = ViewPosition.createAt( nodeAfter.nextSibling );
+						const viewRange = new ViewRange( viewPosition );
+						const newSelection = new ViewSelection();
+
+						newSelection.addRange( viewRange );
+
+						viewDocument.fire( 'selectionChange', {
+							oldSelection: viewDocument.selection,
+							newSelection: newSelection,
+							domSelection: null
+						} );
+					}
+
+					if ( data.keyCode == 37 || data.keyCode == 38 ) {
+						// const modelElement = editingPipeline.mapper.toModelElement( nodeAfter.previousSibling );
+						// const modelPosition = ModelPosition.createAt( modelElement, 'end' );
+						// const modelRange = new ModelLiveRange( modelPosition );
+						//
+						// modelDocument.enqueueChanges( () => {
+						// 	modelDocument.selection.setRanges( [ modelRange ] );
+						// } );
+
+						const viewPosition = ViewPosition.createAt( nodeAfter.previousSibling, 'end' );
+						const viewRange = new ViewRange( viewPosition );
+						const newSelection = new ViewSelection();
+
+						newSelection.addRange( viewRange );
+
+						viewDocument.fire( 'selectionChange', {
+							oldSelection: viewDocument.selection,
+							newSelection: newSelection,
+							domSelection: null
+						} );
+					}
+				}
+			}
+		} );
+
+		// Handle selection conversion.
+		editingPipeline.modelToView.on( 'selection', ( evt, data, consumable, conversionApi ) => {
+			const viewSelection = conversionApi.viewSelection;
+			const range = data.selection.getFirstRange();
+			const nodeAfterStart = range.start.nodeAfter;
+
+			// remove selection from all selected widgets
+			for ( let viewElement of selected ) {
+				viewElement.removeClass( 'selected' );
+			}
+
+			// This could be just one element instead of set.
+			selected.clear();
+
+			if ( !data.selection.isCollapsed && nodeAfterStart && nodeAfterStart.name == 'image' && ModelRange.createOn( nodeAfterStart ).isEqual( range ) ) {
+				viewSelection.setFake( true, { label: 'image fake selection' } );
+				const viewElement = conversionApi.mapper.toViewElement( nodeAfterStart );
+				viewElement.addClass( 'selected' );
+				selected.add( viewElement );
+			}
+		}, { priority: 'low' } );
+	}
+}
+
+function isImageWidget( viewElement ) {
+	// TODO: check CSS class.
+	return viewElement.isWidget && viewElement.name == 'figure' && viewElement.hasClass( 'image' );
+}

+ 99 - 23
packages/ckeditor5-image/src/imageengine.js

@@ -3,13 +3,33 @@
  * For licensing, see LICENSE.md.
  */
 
-import Feature from '/ckeditor5/core/feature.js';
-import buildModelConverter from '/ckeditor5/engine/conversion/buildmodelconverter.js';
-import buildViewConverter from '/ckeditor5/engine/conversion/buildviewconverter.js';
-import ViewContainerElement from '/ckeditor5/engine/view/containerelement.js';
-import ModelElement from '/ckeditor5/engine/model/element.js';
+import Feature from '../core/feature.js';
+import buildModelConverter from '../engine/conversion/buildmodelconverter.js';
+import ViewContainerElement from '../engine/view/containerelement.js';
+import ViewWidgetElement from '../engine/view/widgetelement.js';
+import ViewEmptyElement from '../engine/view/emptyelement.js';
+import ModelElement from '../engine/model/element.js';
 
+/**
+ * The image engine feature.
+ * Registers `image` as block element in document's schema and allows it to have two attributes: `src` and `alt`.
+ *
+ * Creates model to view converters for data and editing pipelines. These converts turns image model representation
+ * to view representation:
+ *
+ *		<figure class="image" contenteditable="false">
+ *			<img src="<source>" alt="<alternative>" />
+ *		</figure>
+ *
+ * Creates also a view to model converter for data pipeline.
+ *
+ * memberof image
+ * extends core.Feature.
+ */
 export default class ImageEngine extends Feature {
+	/**
+	 * @inheritDoc
+	 */
 	init() {
 		const editor = this.editor;
 		const modelDocument = editor.document;
@@ -20,28 +40,84 @@ export default class ImageEngine extends Feature {
 		modelDocument.schema.registerItem( 'image', '$block' );
 		modelDocument.schema.allow( { name: 'image', attributes: [ 'src', 'alt' ] } );
 
-		// Build converter from model to view for data and editing pipelines.
-		buildModelConverter().for( dataPipeline.modelToView, editingPipeline.modelToView )
+		// Build converter from model to view for data pipeline.
+		buildModelConverter().for( dataPipeline.modelToView )
 			.fromElement( 'image' )
 			.toElement( ( data ) => {
-				return widgetize( new ViewContainerElement( 'img', {
-					src: data.item.getAttribute( 'src' ),
-					alt: data.item.getAttribute( 'alt' ),
-				} ) );
+				const modelElement = data.item;
+				const viewImg = new ViewEmptyElement( 'img' );
+
+				if ( modelElement.hasAttribute( 'src' ) ) {
+					viewImg.setAttribute( 'src', modelElement.getAttribute( 'src' ) );
+				}
+
+				if ( modelElement.hasAttribute( 'alt' ) ) {
+					viewImg.setAttribute( 'alt', modelElement.getAttribute( 'alt' ) );
+				}
+
+				return new ViewContainerElement( 'figure', { class: 'image' }, viewImg );
 			} );
 
-		// Build converter from view to model for data pipeline.
-		buildViewConverter().for( dataPipeline.viewToModel )
-			.fromElement( 'img' ).consuming( { name: true, attributes: [ 'src', 'alt' ] } )
-			.toElement( ( viewElement ) => new ModelElement( 'image', {
-				src: viewElement.getAttribute( 'src' ),
-				alt: viewElement.getAttribute( 'alt' )
-			} ) );
-	}
-}
+		// Build converter from model to view for editing pipeline.
+		buildModelConverter().for( editingPipeline.modelToView )
+			.fromElement( 'image' )
+			.toElement( ( data ) => {
+				const modelElement = data.item;
+				const viewImg = new ViewEmptyElement( 'img' );
 
-function widgetize( element ) {
-	element.isWidget = true;
+				if ( modelElement.hasAttribute( 'src' ) ) {
+					viewImg.setAttribute( 'src', modelElement.getAttribute( 'src' ) );
+				}
+
+				if ( modelElement.hasAttribute( 'alt' ) ) {
+					viewImg.setAttribute( 'alt', modelElement.getAttribute( 'alt' ) );
+				}
+
+				return new ViewWidgetElement( 'figure', { class: 'image' }, viewImg );
+			} );
 
-	return element;
+		dataPipeline.viewToModel.on( 'element:figure', ( evt, data, consumable, conversionApi ) => {
+			const viewElement = data.input;
+
+			// Check if image can be converted in current context.
+			if ( !conversionApi.schema.check( { name: 'image', inside: data.context } ) ) {
+				return;
+			}
+
+			// Check if figure element have img inside.
+			if ( viewElement.childCount != 1 ) {
+				return;
+			}
+
+			const viewImg = viewElement.getChild( 0 );
+
+			if ( viewImg.name != 'img' ) {
+				return;
+			}
+
+			// Consume img name and figure element.
+			if ( !consumable.consume( viewImg, { name: true } ) ) {
+				return;
+			}
+
+			if ( !consumable.consume( viewElement, { name: true, class: 'image' } ) ) {
+				return;
+			}
+
+			// Create model element.
+			const modelImage = new ModelElement( 'image' );
+
+			// Add src if one is present.
+			if ( viewImg.hasAttribute( 'src' ) && consumable.consume( viewImg, { attributes: [ 'src' ] } ) ) {
+				modelImage.setAttribute( 'src', viewImg.getAttribute( 'src' ) );
+			}
+
+			// Add alt if one is present.
+			if ( viewImg.hasAttribute( 'alt' ) && consumable.consume( viewImg, { attributes: [ 'alt' ] } ) ) {
+				modelImage.setAttribute( 'alt', viewImg.getAttribute( 'alt' ) );
+			}
+
+			data.output = modelImage;
+		} );
+	}
 }

+ 18 - 0
packages/ckeditor5-image/src/mousedownobserver.js

@@ -0,0 +1,18 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import DomEventObserver from '../engine/view/observer/domeventobserver.js';
+
+export default class MouseDownObserver extends DomEventObserver {
+	constructor( document ) {
+		super( document );
+
+		this.domEventType = 'mousedown';
+	}
+
+	onDomEvent( domEvent ) {
+		this.fire( domEvent.type, domEvent );
+	}
+}

+ 121 - 0
packages/ckeditor5-image/src/tmp.js

@@ -0,0 +1,121 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console:false, document, window */
+
+import ClassicEditor from '/ckeditor5/editor-classic/classic.js';
+import testUtils from '/tests/utils/_utils/utils.js';
+import Feature from '/ckeditor5/core/feature.js';
+import buildModelConverter from '/ckeditor5/engine/conversion/buildmodelconverter.js';
+import buildViewConverter from '/ckeditor5/engine/conversion/buildviewconverter.js';
+import ViewContainerElement from '/ckeditor5/engine/view/containerelement.js';
+import ViewRange from '/ckeditor5/engine/view/range.js';
+import ModelElement from '/ckeditor5/engine/model/element.js';
+import ModelRange from '/ckeditor5/engine/model/range.js';
+import ModelLiveRange from '/ckeditor5/engine/model/liverange.js';
+import ClickObserver from '/ckeditor5/engine/view/observer/clickobserver.js';
+
+let editor, editable, observer;
+
+class BasicImage extends Feature {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const data = editor.data;
+		const editing = editor.editing;
+		const view  = editing.view;
+		const document = editor.document;
+		const selected = new Set();
+
+		// Handle clicks on view elements.
+		view.addObserver( ClickObserver );
+		editor.editing.view.on( 'click', ( eventInfo, domEventData ) => {
+			const target = domEventData.target;
+
+			if ( target.name == 'img' ) {
+				const viewRange = ViewRange.createOn( target );
+				const modelRange = ModelLiveRange.createFromRange( editing.mapper.toModelRange( viewRange ) );
+
+				document.enqueueChanges( ( ) => {
+					document.selection.setRanges( [ modelRange ] );
+				} );
+			}
+		} );
+
+		// Handle selection conversion.
+		editing.modelToView.on( 'selection', ( evt, data, consumable, conversionApi ) => {
+			const viewSelection = conversionApi.viewSelection;
+			const range = data.selection.getFirstRange();
+			const nodeAfterStart = range.start.nodeAfter;
+
+			// remove selection from all selected widgets
+			for ( let viewElement of selected ) {
+				viewElement.setStyle( 'border', '2px solid yellow' );
+			}
+
+			// This could be just one element instead of set.
+			selected.clear();
+
+			if ( !data.selection.isCollapsed && nodeAfterStart && nodeAfterStart.name == 'image' && ModelRange.createOn( nodeAfterStart ).isEqual( range ) ) {
+				viewSelection.setFake( true, { label: 'image fake selection' } );
+				const viewElement = conversionApi.mapper.toViewElement( nodeAfterStart );
+				viewElement.setStyle( 'border', '2px solid red' );
+				selected.add( viewElement );
+			}
+		}, { priority: 'low' } );
+
+		// Allow bold attribute on all inline nodes.
+		// document.schema.allow( { name: '$inline', attributes: [ 'span' ] } );
+		document.schema.registerItem( 'image', '$block' );
+		document.schema.allow( { name: 'image', attributes: [ 'src', 'alt' ] } );
+
+		// Build converter from model to view for data and editing pipelines.
+		buildModelConverter().for( data.modelToView, editing.modelToView )
+			.fromElement( 'image' )
+			.toElement( ( data ) => {
+				return widgetize( new ViewContainerElement( 'img', {
+					src: data.item.getAttribute( 'src' ),
+					alt: data.item.getAttribute( 'alt' ),
+					style: 'border: 2px solid yellow',
+					class: 'ck-image'
+				} ) );
+			} );
+
+		// Build converter from view to model for data pipeline.
+		buildViewConverter().for( data.viewToModel )
+			.fromElement( 'img' ).consuming( { name: true, attributes: [ 'src', 'alt' ] } )
+			.toElement( ( viewElement ) => new ModelElement( 'image', {
+				src: viewElement.getAttribute( 'src' ),
+				alt: viewElement.getAttribute( 'alt' )
+			} ) );
+	}
+}
+
+function initEditor() {
+	ClassicEditor.create( document.querySelector( '#editor' ), {
+		features: [ 'enter', 'typing', 'paragraph', 'undo', 'heading', Test, BasicImage ],
+		toolbar: [ 'headings' ]
+	} )
+		.then( newEditor => {
+			window.editor = editor = newEditor;
+			window.editable = editable = editor.editing.view.getRoot();
+
+			observer = testUtils.createObserver();
+			observer.observe( 'Editable', editable, [ 'isFocused' ] );
+		} )
+		.catch( err => {
+			console.error( err.stack );
+		} );
+}
+
+initEditor();
+
+function widgetize( element ) {
+	element.isWidget = true;
+
+	return element;
+}

+ 137 - 0
packages/ckeditor5-image/tests/imageengine.js

@@ -0,0 +1,137 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from 'tests/core/_utils/virtualtesteditor.js';
+import ImageEngine from 'ckeditor5/image/imageengine.js';
+import { getData as getModelData, setData as setModelData } from 'ckeditor5/engine/dev-utils/model.js';
+import buildViewConverter from 'ckeditor5/engine/conversion/buildviewconverter.js';
+import buildModelConverter from 'ckeditor5/engine/conversion/buildmodelconverter.js';
+
+describe( `ImageEngine`, () => {
+	let editor, document;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+			features: [ ImageEngine ]
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+			document = editor.document;
+		} );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( ImageEngine ) ).to.be.instanceOf( ImageEngine );
+	} );
+
+	it( 'should set proper schema rules', () => {
+		expect( document.schema.check( { name: 'image', attributes: [ 'src', 'alt' ] } ) ).to.be.true;
+	} );
+
+	describe( 'conversion in data pipeline', () => {
+		describe( 'model to view', () => {
+			it( 'should convert', () => {
+				setModelData( document, '<image src="foo.png" alt="alt text"></image>' );
+
+				expect( editor.getData() ).to.equal( '<figure class="image"><img src="foo.png" alt="alt text"></figure>' );
+			} );
+
+			it( 'should convert without alt attribute', () => {
+				setModelData( document, '<image src="foo.png"></image>' );
+
+				expect( editor.getData() ).to.equal( '<figure class="image"><img src="foo.png"></figure>' );
+			} );
+
+			it( 'should convert without src attribute', () => {
+				setModelData( document, '<image alt="alt text"></image>' );
+
+				expect( editor.getData() ).to.equal( '<figure class="image"><img alt="alt text"></figure>' );
+			} );
+		} );
+
+		describe( 'view to model', () => {
+			it( 'should convert image figure', () => {
+				editor.setData( '<figure class="image"><img src="foo.png" alt="alt text" /></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '<image alt="alt text" src="foo.png"></image>' );
+			} );
+
+			it( 'should not convert if there is no image class', () => {
+				editor.setData( '<figure><img src="foo.png" alt="alt text" /></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should not convert if there is no img inside #1', () => {
+				editor.setData( '<figure class="image"></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should not convert if there is no img inside #2', () => {
+				editor.setData( '<figure class="image">test</figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should convert without alt attribute', () => {
+				editor.setData( '<figure class="image"><img src="foo.png" /></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '<image src="foo.png"></image>' );
+			} );
+
+			it( 'should convert without src attribute', () => {
+				editor.setData( '<figure class="image"><img alt="alt text" /></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '<image alt="alt text"></image>' );
+			} );
+
+			it( 'should not convert in wrong context', () => {
+				const data = editor.data;
+				const editing = editor.editing;
+
+				document.schema.registerItem( 'div', '$block' );
+				buildModelConverter().for( data.modelToView, editing.modelToView ).fromElement( 'div' ).toElement( 'div' );
+				buildViewConverter().for( data.viewToModel ).fromElement( 'div' ).toElement( 'div' );
+				document.schema.disallow( { name: 'image', inside: 'div' } );
+
+				editor.setData( '<div><figure class="image"><img src="foo.png" alt="alt text" /></figure></div>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '<div></div>' );
+			} );
+
+			it( 'should not convert if img is already consumed', () => {
+				editor.data.viewToModel.on( 'element:figure', ( evt, data, consumable ) => {
+					const img = data.input.getChild( 0 );
+					consumable.consume( img, { name: true } );
+				}, { priority: 'high' } );
+
+				editor.setData( '<figure class="image"><img src="foo.png" alt="alt text" /></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should not convert if figure is already consumed', () => {
+				editor.data.viewToModel.on( 'element:figure', ( evt, data, consumable ) => {
+					const figure = data.input;
+					consumable.consume( figure, { name: true, class: 'image' } );
+				}, { priority: 'high' } );
+
+				editor.setData( '<figure class="image"><img src="foo.png" alt="alt text" /></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+		} );
+	} );
+} );

+ 6 - 1
packages/ckeditor5-image/tests/manual/image.html

@@ -3,6 +3,11 @@
 </head>
 <div id="editor">
 	<p>This is an editor instance.</p>
-	<img src="http://a.cksource.com/e/1/img/logo-ckeditor-h100.png" />
+	<figure class="image">
+		<img src="logo.png" />
+	</figure>
+	<figure class="image">
+		<img src="logo.png" />
+	</figure>
 	<p>Another line.</p>
 </div>

+ 1 - 1
packages/ckeditor5-image/tests/manual/image.js

@@ -11,7 +11,7 @@ import TypingFeature from '/ckeditor5/typing/typing.js';
 import ParagraphFeature from '/ckeditor5/paragraph/paragraph.js';
 import UndoFeature from '/ckeditor5/undo/undo.js';
 import HeadingFeature from '/ckeditor5/heading/heading.js';
-import ImageFeature from '/ckeditor5/image/imageengine.js';
+import ImageFeature from '/ckeditor5/image/image.js';
 
 ClassicEditor.create( document.querySelector( '#editor' ), {
 	features: [ EnterFeature, TypingFeature, ParagraphFeature, UndoFeature, HeadingFeature, ImageFeature ],

BIN
packages/ckeditor5-image/tests/manual/logo.png


+ 22 - 0
packages/ckeditor5-image/theme/theme.scss

@@ -0,0 +1,22 @@
+//.ck-image {
+//	border: 1px solid transparent;
+//	&__selected {
+//		border: 1px solid ck-border-color();
+//	}
+//}
+
+figure {
+	&.image {
+		text-align: center;
+		cursor: pointer;
+		overflow: hidden;
+
+		img {
+			pointer-events: none;
+		}
+
+		&.selected {
+			background-color: #dedede;
+		}
+	}
+}