-
Notifications
You must be signed in to change notification settings - Fork 4.6k
Editable: Adding an inline link control to the format toolbar #524
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 all commits
28b7e06
d6a006c
609f2a5
328b212
31a5778
910d2dc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| /** | ||
| * Internal dependencies | ||
| */ | ||
| // TODO: We mustn't import by relative path traversing from blocks to editor | ||
| // as we're doing here; instead, we should consider a common components path. | ||
| import IconButton from '../../../editor/components/icon-button'; | ||
| import Toolbar from '../../../editor/components/toolbar'; | ||
|
|
||
| const FORMATTING_CONTROLS = [ | ||
| { | ||
| icon: 'editor-bold', | ||
| title: wp.i18n.__( 'Bold' ), | ||
| format: 'bold' | ||
| }, | ||
| { | ||
| icon: 'editor-italic', | ||
| title: wp.i18n.__( 'Italic' ), | ||
| format: 'italic' | ||
| }, | ||
| { | ||
| icon: 'editor-strikethrough', | ||
| title: wp.i18n.__( 'Strikethrough' ), | ||
| format: 'strikethrough' | ||
| } | ||
| ]; | ||
|
|
||
| class FormatToolbar extends wp.element.Component { | ||
| constructor( props ) { | ||
| super( ...arguments ); | ||
| this.state = { | ||
| linkValue: props.formats.link ? props.formats.link.value : '', | ||
| isEditingLink: false | ||
| }; | ||
| this.addLink = this.addLink.bind( this ); | ||
| this.editLink = this.editLink.bind( this ); | ||
| this.dropLink = this.dropLink.bind( this ); | ||
| this.submitLink = this.submitLink.bind( this ); | ||
| this.updateLinkValue = this.updateLinkValue.bind( this ); | ||
| } | ||
|
|
||
| componentWillUnmout() { | ||
| if ( this.editTimeout ) { | ||
| clearTimeout( this.editTimeout ); | ||
| } | ||
| } | ||
|
|
||
| componentWillReceiveProps( nextProps ) { | ||
| const newState = { | ||
| linkValue: nextProps.formats.link ? nextProps.formats.link.value : '' | ||
| }; | ||
| if ( | ||
| ! this.props.formats.link || | ||
| ! nextProps.formats.link || | ||
| this.props.formats.link.node !== nextProps.formats.link.node | ||
| ) { | ||
| newState.isEditingLink = false; | ||
| } | ||
| this.setState( newState ); | ||
| } | ||
|
|
||
| toggleFormat( format ) { | ||
| return () => { | ||
| this.props.onChange( { | ||
| [ format ]: ! this.props.formats[ format ] | ||
| } ); | ||
| }; | ||
| } | ||
|
|
||
| addLink() { | ||
| if ( ! this.props.formats.link ) { | ||
| this.props.onChange( { link: { value: '' } } ); | ||
|
|
||
| // Debounce the call to avoid the reset in willReceiveProps | ||
| this.editTimeout = setTimeout( () => this.setState( { isEditingLink: true } ) ); | ||
| } | ||
| } | ||
|
|
||
| dropLink() { | ||
| this.props.onChange( { link: undefined } ); | ||
| } | ||
|
|
||
| editLink( event ) { | ||
| event.preventDefault(); | ||
| this.setState( { | ||
| isEditingLink: true | ||
| } ); | ||
| } | ||
|
|
||
| submitLink( event ) { | ||
| event.preventDefault(); | ||
| this.props.onChange( { link: { value: this.state.linkValue } } ); | ||
| this.setState( { | ||
| isEditingLink: false | ||
| } ); | ||
| } | ||
|
|
||
| updateLinkValue( event ) { | ||
| this.setState( { | ||
| linkValue: event.target.value | ||
| } ); | ||
| } | ||
|
|
||
| render() { | ||
| const { formats, focusPosition } = this.props; | ||
| const linkStyle = focusPosition | ||
| ? { position: 'absolute', ...focusPosition } | ||
| : null; | ||
|
|
||
| return ( | ||
| <div className="editable-format-toolbar"> | ||
| <Toolbar | ||
| controls={ | ||
| FORMATTING_CONTROLS | ||
| .map( ( control ) => ( { | ||
| ...control, | ||
| onClick: this.toggleFormat( control.format ), | ||
| isActive: !! formats[ control.format ] | ||
| } ) ) | ||
| .concat( [ { | ||
| icon: 'admin-links', | ||
| title: wp.i18n.__( 'Link' ), | ||
| onClick: this.addLink, | ||
| isActive: !! formats.link | ||
| } ] ) | ||
| } | ||
| /> | ||
|
|
||
| { !! formats.link && this.state.isEditingLink && | ||
| <form | ||
| className="editable-format-toolbar__link-modal" | ||
| style={ linkStyle } | ||
| onSubmit={ this.submitLink }> | ||
| <input | ||
| className="editable-format-toolbar__link-input" | ||
| type="url" | ||
| required | ||
| value={ this.state.linkValue } | ||
| onChange={ this.updateLinkValue } | ||
| placeholder={ wp.i18n.__( 'Paste URL or type' ) } | ||
| /> | ||
| <IconButton icon="editor-break" type="submit" /> | ||
| </form> | ||
| } | ||
|
|
||
| { !! formats.link && ! this.state.isEditingLink && | ||
| <div className="editable-format-toolbar__link-modal" style={ linkStyle }> | ||
| <a className="editable-format-toolbar__link-value" href="" onClick={ this.editLink }> | ||
| { decodeURI( this.state.linkValue ) } | ||
| </a> | ||
| <IconButton icon="edit" onClick={ this.editLink } /> | ||
| <IconButton icon="editor-unlink" onClick={ this.dropLink } /> | ||
| </div> | ||
| } | ||
| </div> | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export default FormatToolbar; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,15 +2,16 @@ | |
| * External dependencies | ||
| */ | ||
| import classnames from 'classnames'; | ||
| import { last, isEqual, capitalize, omitBy } from 'lodash'; | ||
| import { last, isEqual, capitalize, omitBy, forEach, merge } from 'lodash'; | ||
| import { nodeListToReact } from 'dom-react'; | ||
| import { Fill } from 'react-slot-fill'; | ||
| import 'element-closest'; | ||
|
|
||
| /** | ||
| * Internal dependencies | ||
| */ | ||
| import './style.scss'; | ||
|
|
||
| import FormatToolbar from './format-toolbar'; | ||
| // TODO: We mustn't import by relative path traversing from blocks to editor | ||
| // as we're doing here; instead, we should consider a common components path. | ||
| import Toolbar from '../../../editor/components/toolbar'; | ||
|
|
@@ -22,24 +23,6 @@ const formatMap = { | |
| del: 'strikethrough' | ||
| }; | ||
|
|
||
| const FORMATTING_CONTROLS = [ | ||
| { | ||
| icon: 'editor-bold', | ||
| title: wp.i18n.__( 'Bold' ), | ||
| format: 'bold' | ||
| }, | ||
| { | ||
| icon: 'editor-italic', | ||
| title: wp.i18n.__( 'Italic' ), | ||
| format: 'italic' | ||
| }, | ||
| { | ||
| icon: 'editor-strikethrough', | ||
| title: wp.i18n.__( 'Strikethrough' ), | ||
| format: 'strikethrough' | ||
| } | ||
| ]; | ||
|
|
||
| const ALIGNMENT_CONTROLS = [ | ||
| { | ||
| icon: 'editor-alignleft', | ||
|
|
@@ -86,9 +69,11 @@ export default class Editable extends wp.element.Component { | |
| this.onFocus = this.onFocus.bind( this ); | ||
| this.onNodeChange = this.onNodeChange.bind( this ); | ||
| this.onKeyDown = this.onKeyDown.bind( this ); | ||
| this.changeFormats = this.changeFormats.bind( this ); | ||
| this.state = { | ||
| formats: {}, | ||
| alignment: null | ||
| alignment: null, | ||
| bookmark: null | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -104,6 +89,7 @@ export default class Editable extends wp.element.Component { | |
| toolbar: false, | ||
| browser_spellcheck: true, | ||
| entity_encoding: 'raw', | ||
| convert_urls: false, | ||
| setup: this.onSetup, | ||
| formats: { | ||
| strikethrough: { inline: 'del' } | ||
|
|
@@ -147,6 +133,15 @@ export default class Editable extends wp.element.Component { | |
| this.props.onChange( this.savedContent ); | ||
| } | ||
|
|
||
| getRelativePosition( node ) { | ||
| const editorPosition = this.editorNode.closest( '.editor-visual-editor__block' ).getBoundingClientRect(); | ||
| const position = node.getBoundingClientRect(); | ||
| return { | ||
| top: position.top - editorPosition.top + 40 + ( position.height ), | ||
| left: position.left - editorPosition.left - 157 | ||
| }; | ||
| } | ||
|
|
||
| isStartOfEditor() { | ||
| const range = this.editor.selection.getRng(); | ||
| if ( range.startOffset !== 0 || ! range.collapsed ) { | ||
|
|
@@ -225,28 +220,26 @@ export default class Editable extends wp.element.Component { | |
| } ); | ||
| } | ||
|
|
||
| onNodeChange( { parents } ) { | ||
| onNodeChange( { element, parents } ) { | ||
| let alignment = null; | ||
| const formats = {}; | ||
|
|
||
| parents.forEach( ( node ) => { | ||
| const tag = node.nodeName.toLowerCase(); | ||
|
|
||
| if ( formatMap.hasOwnProperty( tag ) ) { | ||
| formats[ formatMap[ tag ] ] = true; | ||
| } else if ( tag === 'a' ) { | ||
| formats.link = { value: node.getAttribute( 'href' ), node }; | ||
| } | ||
|
|
||
| if ( tag === 'p' ) { | ||
| alignment = node.style.textAlign || 'left'; | ||
| } | ||
| } ); | ||
|
|
||
| if ( | ||
| this.state.alignment !== alignment || | ||
| ! isEqual( this.state.formats, formats ) | ||
| ) { | ||
| this.setState( { alignment, formats } ); | ||
| } | ||
| const focusPosition = this.getRelativePosition( element ); | ||
| const bookmark = this.editor.selection.getBookmark( 2, true ); | ||
| this.setState( { alignment, bookmark, formats, focusPosition } ); | ||
| } | ||
|
|
||
| bindEditorNode( ref ) { | ||
|
|
@@ -260,6 +253,7 @@ export default class Editable extends wp.element.Component { | |
| this.editor.selection.moveToBookmark( bookmark ); | ||
| // Saving the editor on updates avoid unecessary onChanges calls | ||
| // These calls can make the focus jump | ||
|
|
||
| this.editor.save(); | ||
| } | ||
|
|
||
|
|
@@ -326,14 +320,35 @@ export default class Editable extends wp.element.Component { | |
| return !! this.state.formats[ format ]; | ||
| } | ||
|
|
||
| toggleFormat( format ) { | ||
| this.editor.focus(); | ||
|
|
||
| if ( this.isFormatActive( format ) ) { | ||
| this.editor.formatter.remove( format ); | ||
| } else { | ||
| this.editor.formatter.apply( format ); | ||
| changeFormats( formats ) { | ||
| if ( this.state.bookmark ) { | ||
| this.editor.selection.moveToBookmark( this.state.bookmark ); | ||
| } | ||
|
|
||
| forEach( formats, ( formatValue, format ) => { | ||
| if ( format === 'link' ) { | ||
| if ( formatValue !== undefined ) { | ||
| const anchor = this.editor.dom.getParent( this.editor.selection.getNode(), 'a' ); | ||
| if ( ! anchor ) { | ||
| this.editor.formatter.remove( 'link' ); | ||
| } | ||
| this.editor.formatter.apply( 'link', { href: formatValue.value }, anchor ); | ||
|
||
| } else { | ||
| this.editor.execCommand( 'Unlink' ); | ||
| } | ||
| } else { | ||
| const isActive = this.isFormatActive( format ); | ||
| if ( isActive && ! formatValue ) { | ||
| this.editor.formatter.remove( format ); | ||
| } else if ( ! isActive && formatValue ) { | ||
| this.editor.formatter.apply( format ); | ||
| } | ||
| } | ||
| } ); | ||
|
|
||
| this.setState( { | ||
| formats: merge( {}, this.state.formats, formats ) | ||
| } ); | ||
| } | ||
|
|
||
| isAlignmentActive( align ) { | ||
|
|
@@ -373,13 +388,7 @@ export default class Editable extends wp.element.Component { | |
| isActive: this.isAlignmentActive( control.align ) | ||
| } ) ) } /> | ||
| } | ||
|
|
||
| <Toolbar | ||
| controls={ FORMATTING_CONTROLS.map( ( control ) => ( { | ||
| ...control, | ||
| onClick: () => this.toggleFormat( control.format ), | ||
| isActive: this.isFormatActive( control.format ) | ||
| } ) ) } /> | ||
| <FormatToolbar focusPosition={ this.state.focusPosition } formats={ this.state.formats } onChange={ this.changeFormats } /> | ||
|
||
| </Fill>, | ||
| element | ||
| ]; | ||
|
|
||


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.
If we're not using
FORMATTING_CONTROLSfor anything else, and we're always concatenating, why not just include the link control in the constant set?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.
They don't have the same
isActiveandonClickprop. Do you prefer a ternary or something?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.
Ah, I think I overlooked how we're mapping to add in instance-bound handlers. This should be fine as-is.