Skip to content

Commit

Permalink
RichText: List: Fix indent/outdent (#12667)
Browse files Browse the repository at this point in the history
* RichText: List: use value to indent/outdent

* Add outdent

* Support multi outdent

* Hold one reference per format

* Keep list formats when indenting to new index

* Remove unneeded parameter

* Rename

* Add logic to change list type

* Add tests

* Add e2e tests

* Add some basic docs. Clean up.

* Remove duplicate wp-tinymce dependency

* Clean up

* Add more docs, fix getSelectedListNode

* Put duplicate text values in constant
  • Loading branch information
ellatrix authored and youknowriad committed Jan 28, 2019
1 parent 0df62c5 commit 6dacd18
Show file tree
Hide file tree
Showing 17 changed files with 785 additions and 79 deletions.
1 change: 0 additions & 1 deletion lib/packages-dependencies.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@
'wp-editor' => array(
'jquery',
'lodash',
'wp-tinymce-lists',
'wp-a11y',
'wp-api-fetch',
'wp-blob',
Expand Down
17 changes: 3 additions & 14 deletions packages/editor/src/components/rich-text/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ export class RichText extends Component {
this.onSplit = this.props.unstableOnSplit;
}

this.onSetup = this.onSetup.bind( this );
this.onFocus = this.onFocus.bind( this );
this.onBlur = this.onBlur.bind( this );
this.onChange = this.onChange.bind( this );
Expand Down Expand Up @@ -137,15 +136,6 @@ export class RichText extends Component {
this.editableRef = node;
}

/**
* Sets a reference to the TinyMCE editor instance.
*
* @param {Editor} editor The editor instance as passed by TinyMCE.
*/
onSetup( editor ) {
this.editor = editor;
}

setFocusedElement() {
if ( this.props.setFocusedElement ) {
this.props.setFocusedElement( this.props.instanceId );
Expand Down Expand Up @@ -837,12 +827,12 @@ export class RichText extends Component {
<div className={ classes }
onFocus={ this.setFocusedElement }
>
{ isSelected && this.editor && this.multilineTag === 'li' && (
{ isSelected && this.multilineTag === 'li' && (
<ListEdit
editor={ this.editor }
onTagNameChange={ onTagNameChange }
tagName={ Tagname }
onSyncDOM={ () => this.onChange( this.createRecord() ) }
value={ record }
onChange={ this.onChange }
/>
) }
{ isSelected && ! inlineToolbar && (
Expand All @@ -865,7 +855,6 @@ export class RichText extends Component {
<Fragment>
<TinyMCE
tagName={ Tagname }
onSetup={ this.onSetup }
style={ style }
record={ record }
valueToEditableHTML={ this.valueToEditableHTML }
Expand Down
122 changes: 84 additions & 38 deletions packages/editor/src/components/rich-text/list-edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
import { Toolbar } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { Fragment } from '@wordpress/element';
import {
indentListItems,
outdentListItems,
changeListType,
} from '@wordpress/rich-text';

/**
* Internal dependencies
Expand All @@ -13,60 +18,105 @@ import { Fragment } from '@wordpress/element';
import { RichTextShortcut } from './shortcut';
import BlockFormatControls from '../block-format-controls';

function isListRootSelected( editor ) {
return (
! editor.selection ||
editor.selection.getNode().closest( 'ol,ul' ) === editor.getBody()
);
}
const { TEXT_NODE, ELEMENT_NODE } = window.Node;

function isActiveListType( editor, tagName, rootTagName ) {
if ( document.activeElement !== editor.getBody() ) {
return tagName === rootTagName;
/**
* Gets the selected list node, which is the closest list node to the start of
* the selection.
*
* @return {?Element} The selected list node, or undefined if none is selected.
*/
function getSelectedListNode() {
const selection = window.getSelection();

if ( selection.rangeCount === 0 ) {
return;
}

const listItem = editor.selection.getNode();
const list = listItem.closest( 'ol,ul' );
let { startContainer } = selection.getRangeAt( 0 );

if ( startContainer.nodeType === TEXT_NODE ) {
startContainer = startContainer.parentNode;
}

if ( ! list ) {
if ( startContainer.nodeType !== ELEMENT_NODE ) {
return;
}

return list.nodeName.toLowerCase() === tagName;
const rootNode = startContainer.closest( '*[contenteditable]' );

if ( ! rootNode || ! rootNode.contains( startContainer ) ) {
return;
}

return startContainer.closest( 'ol,ul' );
}

export const ListEdit = ( { editor, onTagNameChange, tagName, onSyncDOM } ) => (
/**
* Whether or not the root list is selected.
*
* @return {boolean} True if the root list or nothing is selected, false if an
* inner list is selected.
*/
function isListRootSelected() {
const listNode = getSelectedListNode();

// Consider the root list selected if nothing is selected.
return ! listNode || listNode.contentEditable === 'true';
}

/**
* Wether or not the selected list has the given tag name.
*
* @param {string} tagName The tag name the list should have.
* @param {string} rootTagName The current root tag name, to compare with in
* case nothing is selected.
*
* @return {boolean} [description]
*/
function isActiveListType( tagName, rootTagName ) {
const listNode = getSelectedListNode();

if ( ! listNode ) {
return tagName === rootTagName;
}

return listNode.nodeName.toLowerCase() === tagName;
}

export const ListEdit = ( {
onTagNameChange,
tagName,
value,
onChange,
} ) => (
<Fragment>
<RichTextShortcut
type="primary"
character="["
onUse={ () => {
editor.execCommand( 'Outdent' );
onSyncDOM();
onChange( outdentListItems( value ) );
} }
/>
<RichTextShortcut
type="primary"
character="]"
onUse={ () => {
editor.execCommand( 'Indent' );
onSyncDOM();
onChange( indentListItems( value, { type: tagName } ) );
} }
/>
<RichTextShortcut
type="primary"
character="m"
onUse={ () => {
editor.execCommand( 'Indent' );
onSyncDOM();
onChange( indentListItems( value, { type: tagName } ) );
} }
/>
<RichTextShortcut
type="primaryShift"
character="m"
onUse={ () => {
editor.execCommand( 'Outdent' );
onSyncDOM();
onChange( outdentListItems( value ) );
} }
/>
<BlockFormatControls>
Expand All @@ -75,43 +125,39 @@ export const ListEdit = ( { editor, onTagNameChange, tagName, onSyncDOM } ) => (
{
icon: 'editor-ul',
title: __( 'Convert to unordered list' ),
isActive: isActiveListType( editor, 'ul', tagName ),
isActive: isActiveListType( 'ul', tagName ),
onClick() {
if ( isListRootSelected( editor ) ) {
onChange( changeListType( value, { type: 'ul' } ) );

if ( isListRootSelected() ) {
onTagNameChange( 'ul' );
} else {
editor.execCommand( 'InsertUnorderedList' );
onSyncDOM();
}
},
},
{
icon: 'editor-ol',
title: __( 'Convert to ordered list' ),
isActive: isActiveListType( editor, 'ol', tagName ),
isActive: isActiveListType( 'ol', tagName ),
onClick() {
if ( isListRootSelected( editor ) ) {
onChange( changeListType( value, { type: 'ol' } ) );

if ( isListRootSelected() ) {
onTagNameChange( 'ol' );
} else {
editor.execCommand( 'InsertOrderedList' );
onSyncDOM();
}
},
},
{
icon: 'editor-outdent',
title: __( 'Outdent list item' ),
onClick() {
editor.execCommand( 'Outdent' );
onSyncDOM();
onClick: () => {
onChange( outdentListItems( value ) );
},
},
{
icon: 'editor-indent',
title: __( 'Indent list item' ),
onClick() {
editor.execCommand( 'Indent' );
onSyncDOM();
onClick: () => {
onChange( indentListItems( value, { type: tagName } ) );
},
},
] }
Expand Down
5 changes: 0 additions & 5 deletions packages/editor/src/components/rich-text/tinymce.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,16 +200,11 @@ export default class TinyMCE extends Component {
lists_indent_on_tab: false,
};

if ( multilineTag === 'li' ) {
settings.plugins.push( 'lists' );
}

tinymce.init( {
...settings,
target: this.editorNode,
setup: ( editor ) => {
this.editor = editor;
this.props.onSetup( editor );

// TinyMCE resets the element content on initialization, even
// when it's already identical to what exists currently. This
Expand Down
62 changes: 62 additions & 0 deletions packages/rich-text/src/change-list-type.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Internal dependencies
*/

import { LINE_SEPARATOR } from './special-characters';
import { normaliseFormats } from './normalise-formats';
import { getLineIndex } from './get-line-index';
import { getParentLineIndex } from './get-parent-line-index';

/**
* Changes the list type of the selected indented list, if any. Looks at the
* currently selected list item and takes the parent list, then changes the list
* type of this list. When multiple lines are selected, the parent lists are
* takes and changed.
*
* @param {Object} value Value to change.
* @param {Object} newFormat The new list format object. Choose between
* `{ type: 'ol' }` and `{ type: 'ul' }`.
*
* @return {Object} The changed value.
*/
export function changeListType( value, newFormat ) {
const { text, formats, start, end } = value;
const startLineFormats = formats[ getLineIndex( value, start ) ] || [];
const endLineFormats = formats[ getLineIndex( value, end ) ] || [];
const startIndex = getParentLineIndex( value, start );
const newFormats = formats.slice( 0 );
const startCount = startLineFormats.length - 1;
const endCount = endLineFormats.length - 1;

let changed;

for ( let index = startIndex + 1 || 0; index < text.length; index++ ) {
if ( text[ index ] !== LINE_SEPARATOR ) {
continue;
}

if ( ( newFormats[ index ] || [] ).length <= startCount ) {
break;
}

if ( ! newFormats[ index ] ) {
continue;
}

changed = true;
newFormats[ index ] = newFormats[ index ].map( ( format, i ) => {
return i < startCount || i > endCount ? format : newFormat;
} );
}

if ( ! changed ) {
return value;
}

return normaliseFormats( {
text,
formats: newFormats,
start,
end,
} );
}
26 changes: 26 additions & 0 deletions packages/rich-text/src/get-line-index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Internal dependencies
*/

import { LINE_SEPARATOR } from './special-characters';

/**
* Gets the currently selected line index, or the first line index if the
* selection spans over multiple items.
*
* @param {Object} value Value to get the line index from.
* @param {boolean} startIndex Optional index that should be contained by the
* line. Defaults to the selection start of the
* value.
*
* @return {?boolean} The line index. Undefined if not found.
*/
export function getLineIndex( { start, text }, startIndex = start ) {
let index = startIndex;

while ( index-- ) {
if ( text[ index ] === LINE_SEPARATOR ) {
return index;
}
}
}
37 changes: 37 additions & 0 deletions packages/rich-text/src/get-parent-line-index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Internal dependencies
*/

import { LINE_SEPARATOR } from './special-characters';

/**
* Gets the index of the first parent list. To get the parent list formats, we
* go through every list item until we find one with exactly one format type
* less.
*
* @param {Object} value Value to search.
* @param {number} startIndex Index to start search at.
*
* @return {Array} The parent list line index.
*/
export function getParentLineIndex( { text, formats }, startIndex ) {
let index = startIndex;
let startFormats;

while ( index-- ) {
if ( text[ index ] !== LINE_SEPARATOR ) {
continue;
}

const formatsAtIndex = formats[ index ] || [];

if ( ! startFormats ) {
startFormats = formatsAtIndex;
continue;
}

if ( formatsAtIndex.length === startFormats.length - 1 ) {
return index;
}
}
}
Loading

0 comments on commit 6dacd18

Please sign in to comment.