diff --git a/docs/_snippets/features/build-mention-source.html b/docs/_snippets/features/build-mention-source.html new file mode 100644 index 0000000..e69de29 diff --git a/docs/_snippets/features/build-mention-source.js b/docs/_snippets/features/build-mention-source.js new file mode 100644 index 0000000..4006230 --- /dev/null +++ b/docs/_snippets/features/build-mention-source.js @@ -0,0 +1,13 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals window */ + +import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor'; +import Mention from '@ckeditor/ckeditor5-mention/src/mention'; + +ClassicEditor.builtinPlugins.push( Mention ); + +window.ClassicEditor = ClassicEditor; diff --git a/docs/_snippets/features/custom-mention-colors-variables.html b/docs/_snippets/features/custom-mention-colors-variables.html new file mode 100644 index 0000000..6d50d1b --- /dev/null +++ b/docs/_snippets/features/custom-mention-colors-variables.html @@ -0,0 +1,14 @@ + + +
Hello @Ted.
+Hello @Ted Mosby!
+Hello @Ted.
+Have you met...
+Have you met... @Ted Mosby
+ +Same mention twice in data: @Ted Mosby@Ted Mosby
+Hello @Ted.
+Hello @Ted@Ted.
+foo @John bar
' ); + + expect( editor.getData() ).to.equal( 'foo @John bar
' ); + + model.change( writer => { + const paragraph = doc.getRoot().getChild( 0 ); + + writer.setSelection( paragraph, 6 ); + + writer.insertText( 'a', doc.selection.getAttributes(), writer.createPositionAt( paragraph, 6 ) ); + } ); + + expect( editor.getData() ).to.equal( 'foo @Jaohn bar
' ); + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( 'foo @Jaohn bar
' ); + + editor.execute( 'undo' ); + + expect( editor.getData() ).to.equal( 'foo @John bar
' ); + expect( getViewData( editor.editing.view ) ) + .to.equal( 'foo @John bar
' ); + } ); + + // Failing test. See ckeditor/ckeditor5#1645. + it( 'should restore removed mention on removing a text inside mention', () => { + editor.setData( 'foo @John bar
' ); + + expect( editor.getData() ).to.equal( 'foo @John bar
' ); + + model.change( writer => { + const paragraph = doc.getRoot().getChild( 0 ); + + writer.setSelection( paragraph, 7 ); + + model.modifySelection( doc.selection, { direction: 'backward', unit: 'codepoint' } ); + model.deleteContent( doc.selection ); + } ); + + expect( editor.getData() ).to.equal( 'foo @Jhn bar
' ); + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( 'foo @Jhn bar
' ); + + editor.execute( 'undo' ); + + expect( editor.getData() ).to.equal( 'foo @John bar
' ); + expect( getViewData( editor.editing.view ) ) + .to.equal( 'foo @John bar
' ); + } ); + } ); +} ); diff --git a/tests/mention.js b/tests/mention.js new file mode 100644 index 0000000..92d2d5f --- /dev/null +++ b/tests/mention.js @@ -0,0 +1,50 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; + +import Mention from '../src/mention'; +import MentionEditing from '../src/mentionediting'; +import MentionUI from '../src/mentionui'; + +describe( 'Mention', () => { + let editorElement, editor; + + beforeEach( () => { + editorElement = global.document.createElement( 'div' ); + global.document.body.appendChild( editorElement ); + + return ClassicTestEditor + .create( editorElement, { + plugins: [ Mention ] + } ) + .then( newEditor => { + editor = newEditor; + } ); + } ); + + afterEach( () => { + editorElement.remove(); + + return editor.destroy(); + } ); + + it( 'should be loaded', () => { + expect( editor.plugins.get( Mention ) ).to.instanceOf( Mention ); + } ); + + it( 'has proper name', () => { + expect( Mention.pluginName ).to.equal( 'Mention' ); + } ); + + it( 'should load MentionEditing plugin', () => { + expect( editor.plugins.get( MentionEditing ) ).to.instanceOf( MentionEditing ); + } ); + + it( 'should load MentionUI plugin', () => { + expect( editor.plugins.get( MentionUI ) ).to.instanceOf( MentionUI ); + } ); +} ); diff --git a/tests/mentioncommand.js b/tests/mentioncommand.js new file mode 100644 index 0000000..8b233a1 --- /dev/null +++ b/tests/mentioncommand.js @@ -0,0 +1,126 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +import MentionCommand from '../src/mentioncommand'; + +describe( 'MentionCommand', () => { + let editor, command, model, doc, selection; + + beforeEach( () => { + return ModelTestEditor + .create() + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + selection = doc.selection; + + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + model.schema.register( 'x', { inheritAllFrom: '$block' } ); + model.schema.extend( '$text', { allowAttributes: [ 'mention' ] } ); + + command = new MentionCommand( editor ); + } ); + } ); + + afterEach( () => { + command.destroy(); + + return editor.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should return true if characters with the attribute can be placed at caret position', () => { + setData( model, 'foo @John bar
' ); + + 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' ); + expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_marker', '@' ); + expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'name', 'John' ); + + const expectedView = 'foo @John bar
'; + + 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( + '' + + '@John' + + '@John' + + '
' + ); + + // getModelData() merges text blocks with "same" attributes: + // So expected: <$text mention="{"name":"John"}">@John$text><$text mention="{"name":"John"}">@John$text>' + // Is returned as: <$text mention="{"name":"John"}">@John@John$text>' + const paragraph = doc.getRoot().getChild( 0 ); + + expect( paragraph.childCount ).to.equal( 2 ); + + assertTextNode( paragraph.getChild( 0 ) ); + assertTextNode( paragraph.getChild( 1 ) ); + + const firstMentionId = paragraph.getChild( 0 ).getAttribute( 'mention' )._id; + const secondMentionId = paragraph.getChild( 1 ).getAttribute( 'mention' )._id; + + expect( firstMentionId ).to.not.equal( secondMentionId ); + + const expectedView = '@John' + + '@John
'; + + expect( editor.getData() ).to.equal( expectedView ); + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView ); + + function assertTextNode( textNode ) { + expect( textNode ).to.not.be.null; + expect( textNode.hasAttribute( 'mention' ) ).to.be.true; + expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_id' ); + expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_marker', '@' ); + expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'name', 'John' ); + } + } ); + + it( 'should not convert partial mentions', () => { + editor.setData( '@Jo
' ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( '@Jo
'; + + expect( editor.getData() ).to.equal( expectedView ); + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView ); + } ); + + it( 'should not convert empty mentions', () => { + editor.setData( 'foo
' ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( 'foo
'; + + expect( editor.getData() ).to.equal( expectedView ); + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView ); + } ); + } ); + + describe( 'selection post fixer', () => { + beforeEach( () => { + return createTestEditor() + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + } ); + } ); + + it( 'should remove mention attribute from a selection if selection is on right side of a mention', () => { + editor.setData( 'foo @Johnbar
' ); + + model.change( writer => { + const paragraph = doc.getRoot().getChild( 0 ); + + writer.setSelection( paragraph, 9 ); + } ); + + expect( Array.from( doc.selection.getAttributes() ) ).to.deep.equal( [] ); + } ); + + it( 'should allow to type after a mention', () => { + editor.setData( 'foo @Johnbar
' ); + + model.change( writer => { + const paragraph = doc.getRoot().getChild( 0 ); + + writer.setSelection( paragraph, 9 ); + + writer.insertText( ' ', paragraph, 9 ); + } ); + + expect( editor.getData() ).to.equal( 'foo @John bar
' ); + } ); + } ); + + describe( 'removing partial mention post fixer', () => { + beforeEach( () => { + return createTestEditor() + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + } ); + } ); + + it( 'should remove mention on adding a text inside mention', () => { + editor.setData( 'foo @John bar
' ); + + 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' ); + expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_marker', '@' ); + expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'name', 'John' ); + + model.change( writer => { + const paragraph = doc.getRoot().getChild( 0 ); + + writer.setSelection( paragraph, 6 ); + + writer.insertText( 'a', doc.selection.getAttributes(), writer.createPositionAt( paragraph, 6 ) ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( 'foo @Jaohn bar
' ); + } ); + + it( 'should remove mention on removing a text inside mention', () => { + editor.setData( 'foo @John bar
' ); + + const paragraph = doc.getRoot().getChild( 0 ); + + model.change( writer => { + writer.setSelection( paragraph, 6 ); + } ); + + model.enqueueChange( () => { + model.modifySelection( doc.selection, { direction: 'backward', unit: 'codepoint' } ); + model.deleteContent( doc.selection ); + } ); + + expect( editor.getData() ).to.equal( 'foo @ohn bar
' ); + } ); + + it( 'should remove mention on removing a text at the and of a mention', () => { + editor.setData( 'foo @John bar
' ); + + const paragraph = doc.getRoot().getChild( 0 ); + + // Set selection at the end of a John. + model.change( writer => { + writer.setSelection( paragraph, 9 ); + } ); + + model.enqueueChange( () => { + model.modifySelection( doc.selection, { direction: 'backward', unit: 'codepoint' } ); + model.deleteContent( doc.selection ); + } ); + + expect( editor.getData() ).to.equal( 'foo @Joh bar
' ); + } ); + + it( 'should not remove mention on removing a text just after a mention', () => { + editor.setData( 'foo @John bar
' ); + + const paragraph = doc.getRoot().getChild( 0 ); + + // Set selection before bar. + model.change( writer => { + writer.setSelection( paragraph, 10 ); + } ); + + model.enqueueChange( () => { + model.modifySelection( doc.selection, { direction: 'backward', unit: 'codepoint' } ); + model.deleteContent( doc.selection ); + } ); + + expect( editor.getData() ).to.equal( 'foo @Johnbar
' ); + } ); + } ); + + function createTestEditor( mentionConfig ) { + return VirtualTestEditor + .create( { + plugins: [ Paragraph, MentionEditing ], + mention: mentionConfig + } ); + } +} ); diff --git a/tests/mentionui.js b/tests/mentionui.js new file mode 100644 index 0000000..e3a2b01 --- /dev/null +++ b/tests/mentionui.js @@ -0,0 +1,994 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* global window, document, setTimeout, Event */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; +import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; +import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; + +import MentionUI from '../src/mentionui'; +import MentionEditing from '../src/mentionediting'; +import MentionsView from '../src/ui/mentionsview'; + +describe( 'MentionUI', () => { + let editor, model, doc, editingView, mentionUI, editorElement, mentionsView, panelView, listView; + + const staticConfig = { + feeds: [ + { feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] } + ] + }; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + } ); + + afterEach( () => { + sinon.restore(); + editorElement.remove(); + + return editor.destroy(); + } ); + + it( 'should create a plugin instance', () => { + return createClassicTestEditor().then( () => { + expect( mentionUI ).to.instanceOf( Plugin ); + expect( mentionUI ).to.instanceOf( MentionUI ); + } ); + } ); + + describe( 'pluginName', () => { + it( 'should return plugin by its name', () => { + return createClassicTestEditor().then( () => { + expect( editor.plugins.get( 'MentionUI' ) ).to.equal( mentionUI ); + } ); + } ); + } ); + + describe( 'child views', () => { + beforeEach( () => createClassicTestEditor() ); + + describe( 'panelView', () => { + it( 'should create a view instance', () => { + expect( panelView ).to.instanceof( BalloonPanelView ); + } ); + + it( 'should be added to the ui.view.body collection', () => { + expect( Array.from( editor.ui.view.body ) ).to.include( panelView ); + } ); + + it( 'should have disabled arrow', () => { + expect( panelView.withArrow ).to.be.false; + } ); + + it( 'should have added MentionView as a child', () => { + expect( panelView.content.get( 0 ) ).to.be.instanceof( MentionsView ); + } ); + } ); + } ); + + describe( 'position', () => { + let pinSpy; + + const caretRect = { + bottom: 118, + height: 18, + left: 500, + right: 501, + top: 100, + width: 1 + }; + + const balloonRect = { + bottom: 150, + height: 150, + left: 0, + right: 200, + top: 0, + width: 200 + }; + + beforeEach( () => { + return createClassicTestEditor( staticConfig ).then( () => { + pinSpy = sinon.spy( panelView, 'pin' ); + } ); + } ); + + it( 'should properly calculate position data', () => { + setData( model, '