-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Treat autocompletions as tokens with editing boundaries #6577
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
b1a8d13
2db29d8
6ecb723
8eef449
6a4f2c9
20bb619
137f9b6
9e8758b
5aa39e0
62751a8
6a94706
5995871
80e2234
0c0f470
9380f6d
23d2d93
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -231,22 +231,44 @@ export class Autocomplete extends Component { | |
| this.node = node; | ||
| } | ||
|
|
||
| insertCompletion( range, replacement ) { | ||
| const container = document.createElement( 'div' ); | ||
| container.innerHTML = renderToString( replacement ); | ||
| while ( container.firstChild ) { | ||
| const child = container.firstChild; | ||
| container.removeChild( child ); | ||
| range.insertNode( child ); | ||
| range.setStartAfter( child ); | ||
| } | ||
| insertCompletion( range, replacement, completerName ) { | ||
| // Wrap completions so we can treat them as tokens with editing boundaries. | ||
| const tokenWrapper = document.createElement( 'span' ); | ||
| tokenWrapper.classList.add( 'autocomplete-token' ); | ||
| tokenWrapper.classList.add( `autocomplete-token-${ completerName }` ); | ||
|
|
||
| tokenWrapper.innerHTML = renderToString( replacement ); | ||
|
|
||
| range.insertNode( tokenWrapper ); | ||
|
||
| range.setStartAfter( tokenWrapper ); | ||
| range.deleteContents(); | ||
|
|
||
| /* | ||
| * Add non-breaking space after a completion because: | ||
|
||
| * 1. If the inserted token is the last child in Chrome 66 and desktop Safari 11, | ||
| * we can set the cursor after the token and receive user input, | ||
| * but if the input isn't preceded by a space, the user may not place the | ||
| * cursor within the text by clicking. Adding a subsequent non-breaking space | ||
| * avoids this issue. A regular space is insufficient. | ||
| * 2. It seems reasonable to separate a token from subsequent text with a space. | ||
| */ | ||
| tokenWrapper.parentNode.insertBefore( | ||
| document.createTextNode( '\u00A0' ), | ||
| tokenWrapper.nextSibling | ||
| ); | ||
|
|
||
| const selection = window.getSelection(); | ||
| selection.removeAllRanges(); | ||
|
|
||
| const newCursorPosition = document.createRange(); | ||
| newCursorPosition.setStartAfter( tokenWrapper.nextSibling ); | ||
| selection.addRange( newCursorPosition ); | ||
| } | ||
|
|
||
| select( option ) { | ||
| const { onReplace } = this.props; | ||
| const { open, range, query } = this.state; | ||
| const { getOptionCompletion } = open || {}; | ||
| const { getOptionCompletion, name: completerName } = open || {}; | ||
|
|
||
| if ( option.isDisabled ) { | ||
| return; | ||
|
|
@@ -265,14 +287,14 @@ export class Autocomplete extends Component { | |
| if ( 'replace' === action ) { | ||
| onReplace( [ value ] ); | ||
| } else if ( 'insert-at-caret' === action ) { | ||
| this.insertCompletion( range, value ); | ||
| this.insertCompletion( range, value, completerName ); | ||
| } else if ( 'backcompat' === action ) { | ||
| // NOTE: This block should be removed once we no longer support the old completer interface. | ||
| const onSelect = value; | ||
| const deprecatedOptionObject = option.value; | ||
| const selectionResult = onSelect( deprecatedOptionObject.value, range, query ); | ||
| if ( selectionResult !== undefined ) { | ||
| this.insertCompletion( range, selectionResult ); | ||
| this.insertCompletion( range, selectionResult, completerName ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,3 +34,7 @@ | |
| @include button-style__hover; | ||
| } | ||
| } | ||
|
|
||
| .autocomplete-token[data-mce-selected] { | ||
|
||
| background-color: $blue-medium-highlight; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| /** | ||
| * Internal dependencies | ||
| */ | ||
| import '../support/bootstrap'; | ||
| import { newPost, newDesktopBrowserPage } from '../support/utils'; | ||
|
|
||
| describe( 'autocompletion', () => { | ||
| beforeAll( async () => { | ||
| await newDesktopBrowserPage(); | ||
| await newPost(); | ||
| } ); | ||
|
|
||
| it( 'adds a token followed by a non-breaking space and the cursor', async () => { | ||
| await page.click( '.editor-default-block-appender' ); | ||
|
|
||
| const contentEditableHandle = ( | ||
| await page.evaluateHandle( () => document.activeElement ) | ||
| ); | ||
|
|
||
| await contentEditableHandle.asElement().type( '@' ); | ||
| const optionNode = await page.waitForSelector( '.components-autocomplete__result', { timeout: 10000 } ); | ||
| optionNode.click(); | ||
|
|
||
| // Wait for content update. | ||
| await page.waitForFunction( | ||
| ( contentEditableNode ) => contentEditableNode.textContent.length > 0, | ||
| { timeout: 1000 }, | ||
| contentEditableHandle | ||
| ); | ||
|
|
||
| // Confirm we contain the selection. | ||
| expect( await page.evaluate( | ||
| ( contentEditableNode ) => { | ||
| const { anchorNode, isCollapsed } = window.getSelection(); | ||
| return contentEditableNode.contains( anchorNode ) && isCollapsed; | ||
| }, | ||
| contentEditableHandle | ||
| ) ).toBeTruthy(); | ||
|
|
||
| // Confirm the expected content. | ||
| expect( await page.evaluate( | ||
| ( contentEditableNode ) => contentEditableNode.textContent, | ||
| contentEditableHandle | ||
| ) ).toMatch( /^@\w+\u00A0$/ ); | ||
|
|
||
| // Selection is placed after a single-space text node. | ||
| const selectionAnchorHandle = await page.evaluateHandle( () => window.getSelection().anchorNode ); | ||
| expect( await page.evaluate( | ||
| ( anchorNode ) => anchorNode.nodeType === window.Node.TEXT_NODE, | ||
| selectionAnchorHandle | ||
| ) ).toBeTruthy(); | ||
| const nonBreakingSpace = '\u00A0'; | ||
| expect( await page.evaluate( | ||
| ( anchorNode ) => anchorNode.nodeValue, | ||
| selectionAnchorHandle | ||
| ) ).toBe( nonBreakingSpace ); | ||
| expect( await page.evaluate( | ||
| () => window.getSelection().anchorOffset | ||
| ) ).toBe( nonBreakingSpace.length ); | ||
|
|
||
| // The autocomplete token precedes the space. | ||
| const tokenHandle = await page.evaluateHandle( | ||
| ( anchorNode ) => anchorNode.previousSibling, | ||
| selectionAnchorHandle | ||
| ); | ||
| expect( await page.evaluate( | ||
| ( tokenNode ) => tokenNode.nodeType === window.Node.ELEMENT_NODE, | ||
| tokenHandle | ||
| ) ).toBeTruthy(); | ||
| expect( await page.evaluate( | ||
| ( tokenNode ) => tokenNode.classList.contains( 'autocomplete-token' ), | ||
| tokenHandle | ||
| ) ).toBeTruthy(); | ||
| expect( await page.evaluate( | ||
| ( tokenNode ) => /^@\w+$/.test( tokenNode.textContent ), | ||
| tokenHandle | ||
| ) ).toBeTruthy(); | ||
| } ); | ||
| } ); |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't we have a native tinymce API for this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are TinyMCE APIs for this, but I'm guessing this component shouldn't depend on TinyMCE? Only the
RichTextcomponent is aware of it. This is similar to writing flow which has tried to avoid using TinyMCE APIs. If we want we could pass theeditorinstance though.