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

Merge branch 'master' into t/4

# Conflicts:
#	src/mentionui.js
Maciej Gołaszewski 6 лет назад
Родитель
Сommit
baf36b53b9

+ 1 - 0
packages/ckeditor5-mention/package.json

@@ -27,6 +27,7 @@
     "@ckeditor/ckeditor5-table": "^12.0.1",
     "@ckeditor/ckeditor5-typing": "^12.0.1",
     "@ckeditor/ckeditor5-undo": "^11.0.1",
+    "@ckeditor/ckeditor5-widget": "^11.0.1",
     "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^1.0.11",
     "husky": "^1.3.1",

+ 35 - 0
packages/ckeditor5-mention/src/featuredetection.js

@@ -0,0 +1,35 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module mention/featuredetection
+ */
+
+/**
+ * Holds feature detection resolutions used by the mention plugin.
+ *
+ * @protected
+ * @namespace
+ */
+export default {
+	/**
+	 * Indicates whether the current browser supports ES2018 Unicode punctuation groups `\p{P}`.
+	 *
+	 * @type {Boolean}
+	 */
+	isPunctuationGroupSupported: ( function() {
+		let punctuationSupported = false;
+		// Feature detection for Unicode punctuation groups. It's added in ES2018. Currently Firefox and Edge does not support it.
+		// See https://github.com/ckeditor/ckeditor5-mention/issues/44#issuecomment-487002174.
+
+		try {
+			punctuationSupported = '.'.search( new RegExp( '[\\p{P}]', 'u' ) ) === 0;
+		} catch ( error ) {
+			// Firefox throws a SyntaxError when the group is unsupported.
+		}
+
+		return punctuationSupported;
+	}() )
+};

+ 2 - 5
packages/ckeditor5-mention/src/mentioncommand.js

@@ -137,11 +137,8 @@ export default class MentionCommand extends Command {
 			attributesWithMention.set( 'mention', mention );
 
 			// Replace a range with the text with a mention.
-			writer.remove( range );
-			writer.insertText( mentionText, attributesWithMention, range.start );
-
-			// Insert a space after the mention.
-			writer.insertText( ' ', currentAttributes, model.document.selection.focus );
+			model.insertContent( writer.createText( mentionText, attributesWithMention ), range );
+			model.insertContent( writer.createText( ' ', currentAttributes ), range.start.getShiftedBy( mentionText.length ) );
 		} );
 	}
 }

+ 20 - 8
packages/ckeditor5-mention/src/mentionui.js

@@ -12,6 +12,7 @@ import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import featureDetection from './featuredetection';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
@@ -513,8 +514,6 @@ export default class MentionUI extends Plugin {
 
 		return {
 			target: () => {
-				const mentionMarker = editor.model.markers.get( 'mention' );
-
 				let modelRange = mentionMarker.getRange();
 
 				// Target the UI to the model selection range - the marker has been removed so probably the UI will not be shown anyway.
@@ -603,15 +602,28 @@ function getBalloonPanelPositions( preferredPosition ) {
 	];
 }
 
-// Creates a regex pattern for the marker.
+// Creates a RegExp pattern for the marker.
+//
+// Function has to be exported to achieve 100% code coverage.
 //
 // @param {String} marker
 // @param {Number} minimumCharacters
-// @returns {String}
-function createPattern( marker, minimumCharacters ) {
+// @returns {RegExp}
+export function createRegExp( marker, minimumCharacters ) {
 	const numberOfCharacters = minimumCharacters == 0 ? '*' : `{${ minimumCharacters },}`;
+	const patternBase = featureDetection.isPunctuationGroupSupported ? '\\p{Ps}\\p{Pi}"\'' : '\\(\\[{"\'';
+
+	return new RegExp( buildPattern( patternBase, marker, numberOfCharacters ), 'u' );
+}
 
-	return `(^| )(\\${ marker })([_a-zA-Z0-9À-ž]${ numberOfCharacters }?)$`;
+// Helper to build a RegExp pattern string for the marker.
+//
+// @param {String} whitelistedCharacters
+// @param {String} marker
+// @param {Number} minimumCharacters
+// @returns {String}
+function buildPattern( whitelistedCharacters, marker, numberOfCharacters ) {
+	return `(^|[ ${ whitelistedCharacters }])([${ marker }])([_a-zA-Z0-9À-ž]${ numberOfCharacters }?)$`;
 }
 
 // Creates a test callback for the marker to be used in the text watcher instance.
@@ -620,7 +632,7 @@ function createPattern( marker, minimumCharacters ) {
 // @param {Number} minimumCharacters
 // @returns {Function}
 function createTestCallback( marker, minimumCharacters ) {
-	const regExp = new RegExp( createPattern( marker, minimumCharacters ) );
+	const regExp = createRegExp( marker, minimumCharacters );
 
 	return text => regExp.test( text );
 }
@@ -630,7 +642,7 @@ function createTestCallback( marker, minimumCharacters ) {
 // @param {String} marker
 // @returns {Function}
 function createTextMatcher( marker ) {
-	const regExp = new RegExp( createPattern( marker, 0 ) );
+	const regExp = createRegExp( marker, 0 );
 
 	return text => {
 		const match = text.match( regExp );

+ 12 - 4
packages/ckeditor5-mention/src/textwatcher.js

@@ -114,16 +114,17 @@ export default class TextWatcher {
 	 */
 	_getText() {
 		const editor = this.editor;
-		const selection = editor.model.document.selection;
+		const model = editor.model;
+		const selection = model.document.selection;
 
 		// Do nothing if the selection is not collapsed.
 		if ( !selection.isCollapsed ) {
 			return;
 		}
 
-		const block = selection.focus.parent;
+		const rangeBeforeSelection = model.createRange( model.createPositionAt( selection.focus.parent, 0 ), selection.focus );
 
-		return _getText( editor.model.createRangeIn( block ) ).slice( 0, selection.focus.offset );
+		return _getText( rangeBeforeSelection );
 	}
 }
 
@@ -135,7 +136,14 @@ export default class TextWatcher {
  * @returns {String}
  */
 export function _getText( range ) {
-	return Array.from( range.getItems() ).reduce( ( a, b ) => a + b.data, '' );
+	return Array.from( range.getItems() ).reduce( ( rangeText, node ) => {
+		if ( node.is( 'softBreak' ) ) {
+			// Trim text to softBreak
+			return '';
+		}
+
+		return rangeText + node.data;
+	}, '' );
 }
 
 mix( TextWatcher, EmitterMixin );

+ 95 - 2
packages/ckeditor5-mention/tests/manual/mention.js

@@ -12,16 +12,109 @@ import Mention from '../../src/mention';
 import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
 import Font from '@ckeditor/ckeditor5-font/src/font';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import { toWidget, viewToModelPositionOutsideModelElement } from '@ckeditor/ckeditor5-widget/src/utils';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
+class InlineWidget extends Plugin {
+	constructor( editor ) {
+		super( editor );
+
+		editor.model.schema.register( 'placeholder', {
+			allowWhere: '$text',
+			isObject: true,
+			isInline: true,
+			allowAttributes: [ 'type' ]
+		} );
+
+		editor.conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'placeholder',
+			view: ( modelItem, viewWriter ) => {
+				const widgetElement = createPlaceholderView( modelItem, viewWriter );
+
+				return toWidget( widgetElement, viewWriter );
+			}
+		} );
+
+		editor.conversion.for( 'dataDowncast' ).elementToElement( {
+			model: 'placeholder',
+			view: createPlaceholderView
+		} );
+
+		editor.conversion.for( 'upcast' ).elementToElement( {
+			view: 'placeholder',
+			model: ( viewElement, modelWriter ) => {
+				let type = 'general';
+
+				if ( viewElement.childCount ) {
+					const text = viewElement.getChild( 0 );
+
+					if ( text.is( 'text' ) ) {
+						type = text.data.slice( 1, -1 );
+					}
+				}
+
+				return modelWriter.createElement( 'placeholder', { type } );
+			}
+		} );
+
+		editor.editing.mapper.on(
+			'viewToModelPosition',
+			viewToModelPositionOutsideModelElement( editor.model, viewElement => viewElement.name == 'placeholder' )
+		);
+
+		this._createToolbarButton();
+
+		function createPlaceholderView( modelItem, viewWriter ) {
+			const widgetElement = viewWriter.createContainerElement( 'placeholder' );
+			const viewText = viewWriter.createText( '{' + modelItem.getAttribute( 'type' ) + '}' );
+
+			viewWriter.insert( viewWriter.createPositionAt( widgetElement, 0 ), viewText );
+
+			return widgetElement;
+		}
+	}
+
+	_createToolbarButton() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		editor.ui.componentFactory.add( 'placeholder', locale => {
+			const buttonView = new ButtonView( locale );
+
+			buttonView.set( {
+				label: t( 'Insert placeholder' ),
+				tooltip: true,
+				withText: true
+			} );
+
+			this.listenTo( buttonView, 'execute', () => {
+				const model = editor.model;
+
+				model.change( writer => {
+					const placeholder = writer.createElement( 'placeholder', { type: 'placeholder' } );
+
+					model.insertContent( placeholder );
+
+					writer.setSelection( placeholder, 'on' );
+				} );
+			} );
+
+			return buttonView;
+		} );
+	}
+}
 
 ClassicEditor
 	.create( global.document.querySelector( '#editor' ), {
-		plugins: [ ArticlePluginSet, Underline, Font, Mention ],
+		plugins: [ ArticlePluginSet, Underline, Font, Mention, InlineWidget ],
 		toolbar: [
 			'heading',
 			'|', 'bulletedList', 'numberedList', 'blockQuote',
 			'|', 'bold', 'italic', 'underline', 'link',
 			'|', 'fontFamily', 'fontSize', 'fontColor', 'fontBackgroundColor',
-			'|', 'insertTable',
+			'|', 'insertTable', 'placeholder',
 			'|', 'undo', 'redo'
 		],
 		image: {

+ 113 - 2
packages/ckeditor5-mention/tests/mentionui.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* global document, setTimeout, Event */
+/* global window, document, setTimeout, Event */
 
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
@@ -19,7 +19,8 @@ import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextu
 import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
 import env from '@ckeditor/ckeditor5-utils/src/env';
 
-import MentionUI from '../src/mentionui';
+import MentionUI, { createRegExp } from '../src/mentionui';
+import featureDetection from '../src/featuredetection';
 import MentionEditing from '../src/mentionediting';
 import MentionsView from '../src/ui/mentionsview';
 
@@ -445,6 +446,44 @@ describe( 'MentionUI', () => {
 			} );
 		} );
 
+		describe( 'ES2018 RegExp Unicode property escapes fallback', () => {
+			let regExpStub;
+
+			// Cache the original value to restore it after the tests.
+			const originalPunctuationSupport = featureDetection.isPunctuationGroupSupported;
+
+			before( () => {
+				featureDetection.isPunctuationGroupSupported = false;
+			} );
+
+			beforeEach( () => {
+				return createClassicTestEditor( staticConfig )
+					.then( editor => {
+						regExpStub = sinon.stub( window, 'RegExp' );
+
+						return editor;
+					} );
+			} );
+
+			after( () => {
+				featureDetection.isPunctuationGroupSupported = originalPunctuationSupport;
+			} );
+
+			it( 'returns a simplified RegExp for browsers not supporting Unicode punctuation groups', () => {
+				featureDetection.isPunctuationGroupSupported = false;
+				createRegExp( '@', 2 );
+				sinon.assert.calledOnce( regExpStub );
+				sinon.assert.calledWithExactly( regExpStub, '(^|[ \\(\\[{"\'])([@])([_a-zA-Z0-9À-ž]{2,}?)$', 'u' );
+			} );
+
+			it( 'returns a ES2018 RegExp for browsers supporting Unicode punctuation groups', () => {
+				featureDetection.isPunctuationGroupSupported = true;
+				createRegExp( '@', 2 );
+				sinon.assert.calledOnce( regExpStub );
+				sinon.assert.calledWithExactly( regExpStub, '(^|[ \\p{Ps}\\p{Pi}"\'])([@])([_a-zA-Z0-9À-ž]{2,}?)$', 'u' );
+			} );
+		} );
+
 		describe( 'static list with default trigger', () => {
 			beforeEach( () => {
 				return createClassicTestEditor( staticConfig );
@@ -482,6 +521,53 @@ describe( 'MentionUI', () => {
 					} );
 			} );
 
+			it( 'should show panel for matched marker after a <softBreak>', () => {
+				model.schema.register( 'softBreak', {
+					allowWhere: '$text',
+					isInline: true
+				} );
+
+				editor.conversion.for( 'upcast' )
+					.elementToElement( {
+						model: 'softBreak',
+						view: 'br'
+					} );
+
+				editor.conversion.for( 'downcast' )
+					.elementToElement( {
+						model: 'softBreak',
+						view: ( modelElement, viewWriter ) => viewWriter.createEmptyElement( 'br' )
+					} );
+
+				setData( model, '<paragraph>abc<softBreak></softBreak>[] foo</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+						expect( mentionsView.items ).to.have.length( 5 );
+					} );
+			} );
+
+			// Opening parenthesis type characters that should be supported on all environments.
+			for ( const character of [ '(', '\'', '"', '[', '{' ] ) {
+				testOpeningPunctuationCharacter( character );
+			}
+
+			// Excerpt of opening parenthesis type characters that tests ES2018 Unicode property escapes on supported environment.
+			for ( const character of [
+				// Belongs to Ps (Punctuation, Open) group:
+				'〈', '„', '﹛', '⦅', '{',
+				// Belongs to Pi (Punctuation, Initial quote) group:
+				'«', '‹', '⸌', ' ⸂', '⸠'
+			] ) {
+				testOpeningPunctuationCharacter( character, !featureDetection.isPunctuationGroupSupported );
+			}
+
 			it( 'should not show panel for marker in the middle of other word', () => {
 				setData( model, '<paragraph>foo[]</paragraph>' );
 
@@ -1036,6 +1122,31 @@ describe( 'MentionUI', () => {
 					} );
 			} );
 		} );
+
+		function testOpeningPunctuationCharacter( character, skip = false ) {
+			it( `should show panel for matched marker after a "${ character }" character`, function() {
+				if ( skip ) {
+					this.skip();
+				}
+
+				setData( model, '<paragraph>[] foo</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( character, doc.selection.getFirstPosition() );
+				} );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible, 'panel is visible' ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true;
+						expect( mentionsView.items ).to.have.length( 5 );
+					} );
+			} );
+		}
 	} );
 
 	describe( 'panel behavior', () => {