瀏覽代碼

Merged master.

Maciej Bukowski 6 年之前
父節點
當前提交
afefb2dc76

+ 2 - 0
packages/ckeditor5-mention/docs/features/mentions.md

@@ -189,6 +189,8 @@ To a link:
 
 The converters must be defined with a `'high'` priority to be executed before the {@link features/link link} feature's converter and before the default converter of the mention feature. A mention is stored in the model as a {@link framework/guides/architecture/editing-engine#text-attributes text attribute} that stores an object (see {@link module:mention/mention~MentionFeedItem}).
 
+**Note:** The feature prevents copying fragments of existing mentions. If only a part of a mention is selected, it will be copied as plain text. The internal converter with the {@link module:engine/conversion/conversion~ConverterDefinition `'highest'` priority} controls this behaviour; thus, we do not recommend adding mention converters with the `'highest'` priority to avoid collisions and quirky results.
+
 ```js
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {

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

@@ -12,6 +12,7 @@
   "dependencies": {
     "@ckeditor/ckeditor5-core": "^12.1.1",
     "@ckeditor/ckeditor5-ui": "^13.0.0",
+    "@ckeditor/ckeditor5-typing": "^12.0.2",
     "@ckeditor/ckeditor5-utils": "^12.1.1"
   },
   "devDependencies": {

+ 7 - 6
packages/ckeditor5-mention/src/featuredetection.js

@@ -15,21 +15,22 @@
  */
 export default {
 	/**
-	 * Indicates whether the current browser supports ES2018 Unicode punctuation groups `\p{P}`.
+	 * Indicates whether the current browser supports ES2018 Unicode groups like `\p{P}` or `\p{L}`.
 	 *
 	 * @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.
+	isUnicodeGroupSupported: ( function() {
+		let isSupported = false;
+
+		// Feature detection for Unicode groups. Added in ES2018. Currently Firefox and Edge do not support it.
 		// See https://github.com/ckeditor/ckeditor5-mention/issues/44#issuecomment-487002174.
 
 		try {
-			punctuationSupported = '.'.search( new RegExp( '[\\p{P}]', 'u' ) ) === 0;
+			isSupported = 'ć'.search( new RegExp( '[\\p{L}]', 'u' ) ) === 0;
 		} catch ( error ) {
 			// Firefox throws a SyntaxError when the group is unsupported.
 		}
 
-		return punctuationSupported;
+		return isSupported;
 	}() )
 };

+ 43 - 5
packages/ckeditor5-mention/src/mentionediting.js

@@ -40,6 +40,7 @@ export default class MentionEditing extends Plugin {
 		// Allow the mention attribute on all text nodes.
 		model.schema.extend( '$text', { allowAttributes: 'mention' } );
 
+		// Upcast conversion.
 		editor.conversion.for( 'upcast' ).elementToAttribute( {
 			view: {
 				name: 'span',
@@ -52,10 +53,12 @@ export default class MentionEditing extends Plugin {
 			}
 		} );
 
+		// Downcast conversion.
 		editor.conversion.for( 'downcast' ).attributeToElement( {
 			model: 'mention',
 			view: createViewMentionElement
 		} );
+		editor.conversion.for( 'downcast' ).add( preventPartialMentionDowncast );
 
 		doc.registerPostFixer( writer => removePartialMentionPostFixer( writer, doc, model.schema ) );
 		doc.registerPostFixer( writer => extendAttributeOnMentionPostFixer( writer, doc ) );
@@ -98,6 +101,31 @@ export function _toMentionAttribute( viewElementOrMention, data ) {
 	return _addMentionAttributes( baseMentionData, data );
 }
 
+// A converter that blocks partial mention from being converted.
+//
+// This converter is registered with 'highest' priority in order to consume mention attribute before it is converted by
+// any other converters. This converter only consumes partial mention - those whose `_text` attribute is not equal to text with mention
+// attribute. This may happen when copying part of mention text.
+//
+// @param {module:engine/conversion/dwoncastdispatcher~DowncastDispatcher}
+function preventPartialMentionDowncast( dispatcher ) {
+	dispatcher.on( 'attribute:mention', ( evt, data, conversionApi ) => {
+		const mention = data.attributeNewValue;
+
+		if ( !data.item.is( 'textProxy' ) || !mention ) {
+			return;
+		}
+
+		const start = data.range.start;
+		const textNode = start.textNode || start.nodeAfter;
+
+		if ( textNode.data != mention._text ) {
+			// Consume item to prevent partial mention conversion.
+			conversionApi.consumable.consume( data.item, evt.name );
+		}
+	}, { priority: 'highest' } );
+}
+
 // Creates a mention element from the mention data.
 //
 // @param {Object} mention
@@ -121,7 +149,8 @@ function createViewMentionElement( mention, viewWriter ) {
 	return viewWriter.createAttributeElement( 'span', attributes, options );
 }
 
-// Model post-fixer that disallows typing with selection when the selection is placed after the text node with the mention attribute.
+// Model post-fixer that disallows typing with selection when the selection is placed after the text node with the mention attribute or
+// before a text node with mention attribute.
 //
 // @param {module:engine/model/writer~Writer} writer
 // @param {module:engine/model/document~Document} doc
@@ -130,15 +159,24 @@ function selectionMentionAttributePostFixer( writer, doc ) {
 	const selection = doc.selection;
 	const focus = selection.focus;
 
-	if ( selection.isCollapsed && selection.hasAttribute( 'mention' ) && isNodeBeforeAText( focus ) ) {
+	if ( selection.isCollapsed && selection.hasAttribute( 'mention' ) && shouldNotTypeWithMentionAt( focus ) ) {
 		writer.removeSelectionAttribute( 'mention' );
 
 		return true;
 	}
+}
 
-	function isNodeBeforeAText( position ) {
-		return position.nodeBefore && position.nodeBefore.is( 'text' );
-	}
+// Helper function to detect if mention attribute should be removed from selection.
+// This check makes only sense if the selection has mention attribute.
+//
+// The mention attribute should be removed from a selection when selection focus is placed:
+// a) after a text node
+// b) the position is at parents start - the selection will set attributes from node after.
+function shouldNotTypeWithMentionAt( position ) {
+	const isAtStart = position.isAtStart;
+	const isAfterAMention = position.nodeBefore && position.nodeBefore.is( 'text' );
+
+	return isAfterAMention || isAtStart;
 }
 
 // Model post-fixer that removes the mention attribute from the modified text node.

+ 21 - 48
packages/ckeditor5-mention/src/mentionui.js

@@ -17,7 +17,7 @@ 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';
 
-import TextWatcher from './textwatcher';
+import TextWatcher from '@ckeditor/ckeditor5-typing/src/textwatcher';
 
 import MentionsView from './ui/mentionsview';
 import DomWrapperView from './ui/domwrapperview';
@@ -213,17 +213,11 @@ export default class MentionUI extends Plugin {
 			const item = data.item;
 			const marker = data.marker;
 
-			const watcher = this._getWatcher( marker );
-
-			const text = watcher.last;
-
-			const textMatcher = createTextMatcher( marker );
-			const matched = textMatcher( text );
-			const matchedTextLength = matched.marker.length + matched.feedText.length;
+			const mentionMarker = editor.model.markers.get( 'mention' );
 
 			// Create a range on matched text.
 			const end = model.createPositionAt( model.document.selection.focus );
-			const start = end.getShiftedBy( -matchedTextLength );
+			const start = model.createPositionAt( mentionMarker.getStart() );
 			const range = model.createRange( start, end );
 
 			this._hideUIAndRemoveMarker();
@@ -274,18 +268,15 @@ export default class MentionUI extends Plugin {
 	 * @private
 	 * @param {String} marker
 	 * @param {Number} minimumCharacters
-	 * @returns {module:mention/textwatcher~TextWatcher}
+	 * @returns {module:typing/textwatcher~TextWatcher}
 	 */
 	_setupTextWatcherForFeed( marker, minimumCharacters ) {
 		const editor = this.editor;
 
-		const watcher = new TextWatcher( editor, createTestCallback( marker, minimumCharacters ), createTextMatcher( marker ) );
+		const watcher = new TextWatcher( editor.model, createTestCallback( marker, minimumCharacters ) );
 
 		watcher.on( 'matched', ( evt, data ) => {
-			const matched = data.matched;
-
 			const selection = editor.model.document.selection;
-
 			const focus = selection.focus;
 
 			// The text watcher listens only to changed range in selection - so the selection attributes are not yet available
@@ -301,8 +292,7 @@ export default class MentionUI extends Plugin {
 				return;
 			}
 
-			const { feedText, marker } = matched;
-
+			const feedText = getFeedText( marker, data.text );
 			const matchedTextLength = marker.length + feedText.length;
 
 			// Create a marker range.
@@ -349,19 +339,6 @@ export default class MentionUI extends Plugin {
 	}
 
 	/**
-	 * Returns the registered text watcher for the marker.
-	 *
-	 * @private
-	 * @param {String} marker
-	 * @returns {module:mention/textwatcher~TextWatcher}
-	 */
-	_getWatcher( marker ) {
-		const { watcher } = this._mentionsConfigurations.get( marker );
-
-		return watcher;
-	}
-
-	/**
 	 * Shows the mentions balloon. If the panel is already visible, it will reposition it.
 	 *
 	 * @private
@@ -555,19 +532,20 @@ function getBalloonPanelPositions( preferredPosition ) {
 // @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' );
-}
+	const openAfterCharacters = featureDetection.isUnicodeGroupSupported ? '\\p{Ps}\\p{Pi}"\'' : '\\(\\[{"\'';
+	const mentionCharacters = featureDetection.isUnicodeGroupSupported ? '\\p{L}\\p{N}' : 'a-zA-ZÀ-ž0-9';
 
-// 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 }?)$`;
+	// The pattern consists of 3 groups:
+	// - 0 (non-capturing): Opening sequence - start of the line, space or an opening punctuation character like "(" or "\"",
+	// - 1: The marker character,
+	// - 2: Mention input (taking the minimal length into consideration to trigger the UI),
+	//
+	// The pattern matches up to the caret (end of string switch - $).
+	//               (0:      opening sequence       )(1:  marker   )(2:                typed mention                 )$
+	const pattern = `(?:^|[ ${ openAfterCharacters }])([${ marker }])([_${ mentionCharacters }]${ numberOfCharacters })$`;
+
+	return new RegExp( pattern, 'u' );
 }
 
 // Creates a test callback for the marker to be used in the text watcher instance.
@@ -585,17 +563,12 @@ function createTestCallback( marker, minimumCharacters ) {
 //
 // @param {String} marker
 // @returns {Function}
-function createTextMatcher( marker ) {
+function getFeedText( marker, text ) {
 	const regExp = createRegExp( marker, 0 );
 
-	return text => {
-		const match = text.match( regExp );
+	const match = text.match( regExp );
 
-		const marker = match[ 2 ];
-		const feedText = match[ 3 ];
-
-		return { marker, feedText };
-	};
+	return match[ 2 ];
 }
 
 // The default feed callback.

+ 0 - 150
packages/ckeditor5-mention/src/textwatcher.js

@@ -1,150 +0,0 @@
-/**
- * @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/textwatcher
- */
-
-import mix from '@ckeditor/ckeditor5-utils/src/mix';
-import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
-
-/**
- * The text watcher feature.
- *
- * Fires {@link module:mention/textwatcher~TextWatcher#event:matched `matched`} and
- * {@link module:mention/textwatcher~TextWatcher#event:unmatched `unmatched`} events on typing or selection changes.
- *
- * @private
- */
-export default class TextWatcher {
-	/**
-	 * Creates a text watcher instance.
-	 * @param {module:core/editor/editor~Editor} editor
-	 * @param {Function} testCallback The function used to match the text.
-	 * @param {Function} textMatcherCallback The function used to process matched text.
-	 */
-	constructor( editor, testCallback, textMatcherCallback ) {
-		this.editor = editor;
-		this.testCallback = testCallback;
-		this.textMatcher = textMatcherCallback;
-
-		this.hasMatch = false;
-
-		this._startListening();
-	}
-
-	/**
-	 * The last matched text.
-	 *
-	 * @property {String}
-	 */
-	get last() {
-		return this._getText();
-	}
-
-	/**
-	 * Starts listening to the editor for typing and selection events.
-	 *
-	 * @private
-	 */
-	_startListening() {
-		const editor = this.editor;
-
-		editor.model.document.selection.on( 'change:range', ( evt, { directChange } ) => {
-			// Indirect changes (i.e. when the user types or external changes are applied) are handled in the document's change event.
-			if ( !directChange ) {
-				return;
-			}
-
-			this._evaluateTextBeforeSelection();
-		} );
-
-		editor.model.document.on( 'change:data', ( evt, batch ) => {
-			if ( batch.type == 'transparent' ) {
-				return false;
-			}
-
-			this._evaluateTextBeforeSelection();
-		} );
-	}
-
-	/**
-	 * Checks the editor content for matched text.
-	 *
-	 * @fires matched
-	 * @fires unmatched
-	 *
-	 * @private
-	 */
-	_evaluateTextBeforeSelection() {
-		const text = this._getText();
-
-		const textHasMatch = this.testCallback( text );
-
-		if ( !textHasMatch && this.hasMatch ) {
-			/**
-			 * Fired whenever the text does not match anymore. Fired only when the text watcher found a match.
-			 *
-			 * @event unmatched
-			 */
-			this.fire( 'unmatched' );
-		}
-
-		this.hasMatch = textHasMatch;
-
-		if ( textHasMatch ) {
-			const matched = this.textMatcher( text );
-
-			/**
-			 * Fired whenever the text watcher found a match.
-			 *
-			 * @event matched
-			 */
-			this.fire( 'matched', { text, matched } );
-		}
-	}
-
-	/**
-	 * Returns the text before the caret from the current selection block.
-	 *
-	 * @returns {String|undefined} The text from the block or undefined if the selection is not collapsed.
-	 * @private
-	 */
-	_getText() {
-		const editor = this.editor;
-		const model = editor.model;
-		const selection = model.document.selection;
-
-		// Do nothing if the selection is not collapsed.
-		if ( !selection.isCollapsed ) {
-			return;
-		}
-
-		const rangeBeforeSelection = model.createRange( model.createPositionAt( selection.focus.parent, 0 ), selection.focus );
-
-		return _getText( rangeBeforeSelection );
-	}
-}
-
-/**
- * Returns the whole text from a given range by adding all data from the text nodes together.
- *
- * @protected
- * @param {module:engine/model/range~Range} range
- * @returns {String}
- */
-export function _getText( range ) {
-	return Array.from( range.getItems() ).reduce( ( rangeText, node ) => {
-		if ( node.is( 'softBreak' ) ) {
-			// Trim text to softBreak
-			return '';
-		}
-
-		return rangeText + node.data;
-	}, '' );
-}
-
-mix( TextWatcher, EmitterMixin );
-

+ 155 - 4
packages/ckeditor5-mention/tests/mentionediting.js

@@ -6,10 +6,11 @@
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import { getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
-import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import { stringify as stringifyView, getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 
-import MentionEditing from '../src/mentionediting';
+import MentionEditing, { _toMentionAttribute } from '../src/mentionediting';
 import MentionCommand from '../src/mentioncommand';
 
 describe( 'MentionEditing', () => {
@@ -84,6 +85,25 @@ describe( 'MentionEditing', () => {
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
 		} );
 
+		it( 'should be overridable', () => {
+			addCustomMentionConverters( editor );
+
+			editor.setData( '<p>Hello <b class="mention" data-mention="@Ted Mosby">Ted Mosby</b></p>' );
+
+			const textNode = doc.getRoot().getChild( 0 ).getChild( 1 );
+
+			expect( textNode ).to.not.be.null;
+			expect( textNode.hasAttribute( 'mention' ) ).to.be.true;
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'id', '@Ted Mosby' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_text', 'Ted Mosby' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_uid' );
+
+			const expectedView = '<p>Hello <b class="mention" data-mention="@Ted Mosby">Ted Mosby</b></p>';
+
+			expect( editor.getData() ).to.equal( expectedView );
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
+		} );
+
 		it( 'should convert consecutive mentions spans as two text nodes and two spans in the view', () => {
 			editor.setData(
 				'<p>' +
@@ -122,7 +142,7 @@ describe( 'MentionEditing', () => {
 			}
 		} );
 
-		it( 'should not convert partial mentions', () => {
+		it( 'should upcast partial mention', () => {
 			editor.setData( '<p><span class="mention" data-mention="@John">@Jo</span></p>' );
 
 			const textNode = doc.getRoot().getChild( 0 ).getChild( 0 );
@@ -139,6 +159,72 @@ describe( 'MentionEditing', () => {
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
 		} );
 
+		it( 'should not downcast partial mention (default converter)', done => {
+			editor.setData( '<p>Hello <span class="mention" data-mention="@John">@John</span></p>' );
+
+			model.change( writer => {
+				const start = writer.createPositionAt( doc.getRoot().getChild( 0 ), 0 );
+				const end = writer.createPositionAt( doc.getRoot().getChild( 0 ), 9 );
+				writer.setSelection( writer.createRange( start, end ) );
+			} );
+
+			const dataTransferMock = createDataTransfer();
+			const preventDefaultSpy = sinon.spy();
+
+			editor.editing.view.document.on( 'clipboardOutput', ( evt, data ) => {
+				expect( stringifyView( data.content ) ).to.equal( 'Hello @Jo' );
+
+				done();
+			} );
+
+			editor.editing.view.document.fire( 'copy', {
+				dataTransfer: dataTransferMock,
+				preventDefault: preventDefaultSpy
+			} );
+		} );
+
+		it( 'should not downcast partial mention (custom converter)', done => {
+			addCustomMentionConverters( editor );
+
+			editor.conversion.for( 'downcast' ).attributeToElement( {
+				model: 'mention',
+				view: ( modelAttributeValue, viewWriter ) => {
+					if ( !modelAttributeValue ) {
+						return;
+					}
+
+					return viewWriter.createAttributeElement( 'a', {
+						class: 'mention',
+						'data-mention': modelAttributeValue.id,
+						'href': modelAttributeValue.link
+					}, { id: modelAttributeValue._uid } );
+				},
+				converterPriority: 'high'
+			} );
+
+			editor.setData( '<p>Hello <b class="mention" data-mention="@Ted Mosby">Ted Mosby</b></p>' );
+
+			model.change( writer => {
+				const start = writer.createPositionAt( doc.getRoot().getChild( 0 ), 0 );
+				const end = writer.createPositionAt( doc.getRoot().getChild( 0 ), 9 );
+				writer.setSelection( writer.createRange( start, end ) );
+			} );
+
+			const dataTransferMock = createDataTransfer();
+			const preventDefaultSpy = sinon.spy();
+
+			editor.editing.view.document.on( 'clipboardOutput', ( evt, data ) => {
+				expect( stringifyView( data.content ) ).to.equal( 'Hello Ted' );
+
+				done();
+			} );
+
+			editor.editing.view.document.fire( 'copy', {
+				dataTransfer: dataTransferMock,
+				preventDefault: preventDefaultSpy
+			} );
+		} );
+
 		it( 'should not convert empty mentions', () => {
 			editor.setData( '<p>foo<span class="mention" data-mention="@John"></span></p>' );
 
@@ -186,6 +272,25 @@ describe( 'MentionEditing', () => {
 
 			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 		} );
+
+		it( 'should not allow to type with mention attribute before mention', () => {
+			editor.setData( '<p><span class="mention" data-mention="@John">@John</span> bar</p>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			// Set selection before mention.
+			model.change( writer => {
+				writer.setSelection( paragraph, 0 );
+			} );
+
+			expect( Array.from( doc.selection.getAttributes() ) ).to.deep.equal( [] );
+
+			model.change( writer => {
+				writer.insertText( 'a', doc.selection.getAttributes(), writer.createPositionAt( paragraph, 0 ) );
+			} );
+
+			expect( editor.getData() ).to.equal( '<p>a<span class="mention" data-mention="@John">@John</span> bar</p>' );
+		} );
 	} );
 
 	describe( 'removing partial mention post-fixer', () => {
@@ -526,8 +631,54 @@ describe( 'MentionEditing', () => {
 	function createTestEditor( mentionConfig ) {
 		return VirtualTestEditor
 			.create( {
-				plugins: [ Paragraph, MentionEditing ],
+				plugins: [ Paragraph, MentionEditing, Clipboard ],
 				mention: mentionConfig
 			} );
 	}
+
+	function createDataTransfer() {
+		const store = new Map();
+
+		return {
+			setData( type, data ) {
+				store.set( type, data );
+			},
+
+			getData( type ) {
+				return store.get( type );
+			}
+		};
+	}
 } );
+
+function addCustomMentionConverters( editor ) {
+	editor.conversion.for( 'upcast' ).elementToAttribute( {
+		view: {
+			name: 'b',
+			key: 'data-mention',
+			classes: 'mention'
+		},
+		model: {
+			key: 'mention',
+			value: viewItem => {
+				return _toMentionAttribute( viewItem );
+			}
+		},
+		converterPriority: 'high'
+	} );
+
+	editor.conversion.for( 'downcast' ).attributeToElement( {
+		model: 'mention',
+		view: ( modelAttributeValue, viewWriter ) => {
+			if ( !modelAttributeValue ) {
+				return;
+			}
+
+			return viewWriter.createAttributeElement( 'b', {
+				class: 'mention',
+				'data-mention': modelAttributeValue.id
+			}, { id: modelAttributeValue._uid } );
+		},
+		converterPriority: 'high'
+	} );
+}

文件差異過大導致無法顯示
+ 1268 - 1218
packages/ckeditor5-mention/tests/mentionui.js