From d95c7d61d8ca284394a5aa77e9f74f9b3aaea24e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ella=20Van=C2=A0Dorpe?= Date: Fri, 26 Oct 2018 10:59:46 +0200 Subject: [PATCH] Format API (#10209) * Formatting API * Fix image test * WIP: create format lib * Adjust internal value to use format attributes and name instead of element properties * Add activeAttributes prop * Add FillToolbarSlot and Shortcut * Editor: Add keyboard shortcuts support for formatters * Fix rebase error * Fix shortcuts * Remove registerCoreFormats * Address feedback * Simplify filling slots * Fix typos * Fix inserter, move url function * Fix rebase error * Enqueue format library styles * Fix formatting on collapsed selection * Remove Token API * Remove obsolete RichText.Siblings slot * Remove all style imports * Fix after rebase * Fix dependency issue * Use stable key for list of blocks in the inserter * Fix Eslint issue for unsuded lodash method * Move Inline Elements to its own component * Import rich-text store only in the entry point (follow other modules) * Rich Text: Expose unregister format type method * Don't use index for React element keys --- docs/manifest.json | 6 + lib/client-assets.php | 34 +++- package-lock.json | 19 +- package.json | 1 + packages/components/src/index.js | 1 + packages/components/src/style.scss | 1 + .../components/src/toolbar-button/index.js | 50 +++++ .../components/src/toolbar-button/style.scss | 77 ++++++++ .../toolbar-button-container.js | 0 .../toolbar-button-container.native.js | 0 packages/components/src/toolbar/index.js | 28 +-- packages/components/src/toolbar/style.scss | 78 -------- packages/components/src/toolbar/test/index.js | 33 ++-- packages/edit-post/package.json | 1 + .../src/components/block-types-list/index.js | 107 ++++------- .../components/block-types-list/style.scss | 115 ------------ .../components/inserter-list-item/index.js | 76 ++++++++ .../components/inserter-list-item/style.scss | 114 ++++++++++++ .../editor/src/components/inserter/index.js | 2 - .../components/inserter/inline-elements.js | 35 ++++ .../editor/src/components/inserter/menu.js | 4 +- .../src/components/inserter/results-portal.js | 40 ---- .../editor/src/components/inserter/style.scss | 2 +- .../rich-text/core-tokens/image/index.js | 50 ----- .../rich-text/core-tokens/image/style.scss | 11 -- .../components/rich-text/core-tokens/index.js | 6 - .../src/components/rich-text/format-edit.js | 103 +++++++++++ .../rich-text/format-toolbar/index.js | 173 ++---------------- .../rich-text/format-toolbar/style.scss | 15 -- .../format-toolbar/toolbar-container.js | 8 - .../rich-text/formatting-controls.js | 37 ---- .../editor/src/components/rich-text/index.js | 28 +-- .../src/components/rich-text/index.native.js | 25 ++- .../src/components/rich-text/tinymce.js | 10 + .../src/components/rich-text/tokens/index.js | 96 ---------- .../components/rich-text/tokens/ui/index.js | 94 ---------- .../components/rich-text/tokens/ui/style.scss | 7 - packages/editor/src/store/actions.js | 15 -- packages/editor/src/store/index.js | 15 -- packages/editor/src/store/reducer.js | 23 --- packages/editor/src/style.scss | 3 +- packages/editor/src/utils/test/url.js | 29 --- packages/editor/src/utils/url.js | 19 -- packages/format-library/.npmrc | 1 + packages/format-library/README.md | 15 ++ packages/format-library/package.json | 36 ++++ packages/format-library/src/bold/index.js | 36 ++++ packages/format-library/src/code/index.js | 29 +++ packages/format-library/src/image/index.js | 80 ++++++++ packages/format-library/src/index.js | 25 +++ packages/format-library/src/italic/index.js | 36 ++++ packages/format-library/src/link/index.js | 117 ++++++++++++ .../src/link/inline.js} | 135 +++++++------- .../src/link}/positioned-at-selection.js | 0 packages/format-library/src/link/style.scss | 14 ++ .../format-library/src/strikethrough/index.js | 36 ++++ packages/format-library/src/style.scss | 1 + packages/rich-text/package.json | 4 +- packages/rich-text/src/apply-format.js | 37 ++-- packages/rich-text/src/create.js | 59 +++++- packages/rich-text/src/get-format-type.js | 15 ++ packages/rich-text/src/get-format-types.js | 13 ++ packages/rich-text/src/index.js | 8 + packages/rich-text/src/insert-object.js | 36 ++++ .../rich-text/src/register-format-type.js | 84 +++++++++ packages/rich-text/src/special-characters.js | 1 + packages/rich-text/src/store/actions.js | 33 ++++ packages/rich-text/src/store/index.js | 13 ++ packages/rich-text/src/store/reducer.js | 33 ++++ packages/rich-text/src/store/selectors.js | 30 +++ packages/rich-text/src/store/test/reducer.js | 47 +++++ packages/rich-text/src/test/apply-format.js | 13 +- packages/rich-text/src/test/create.js | 6 +- packages/rich-text/src/test/to-dom.js | 5 + packages/rich-text/src/test/to-html-string.js | 5 + packages/rich-text/src/to-tree.js | 36 +++- packages/rich-text/src/toggle-format.js | 26 +++ .../rich-text/src/unregister-format-type.js | 27 +++ packages/url/src/index.js | 19 ++ packages/url/src/test/index.test.js | 27 +++ webpack.config.js | 1 + 81 files changed, 1672 insertions(+), 1058 deletions(-) create mode 100644 packages/components/src/toolbar-button/index.js create mode 100644 packages/components/src/toolbar-button/style.scss rename packages/components/src/{toolbar => toolbar-button}/toolbar-button-container.js (100%) rename packages/components/src/{toolbar => toolbar-button}/toolbar-button-container.native.js (100%) create mode 100644 packages/editor/src/components/inserter-list-item/index.js create mode 100644 packages/editor/src/components/inserter-list-item/style.scss create mode 100644 packages/editor/src/components/inserter/inline-elements.js delete mode 100644 packages/editor/src/components/inserter/results-portal.js delete mode 100644 packages/editor/src/components/rich-text/core-tokens/image/index.js delete mode 100644 packages/editor/src/components/rich-text/core-tokens/image/style.scss delete mode 100644 packages/editor/src/components/rich-text/core-tokens/index.js create mode 100644 packages/editor/src/components/rich-text/format-edit.js delete mode 100644 packages/editor/src/components/rich-text/format-toolbar/toolbar-container.js delete mode 100644 packages/editor/src/components/rich-text/formatting-controls.js delete mode 100644 packages/editor/src/components/rich-text/tokens/index.js delete mode 100644 packages/editor/src/components/rich-text/tokens/ui/index.js delete mode 100644 packages/editor/src/components/rich-text/tokens/ui/style.scss delete mode 100644 packages/editor/src/utils/test/url.js create mode 100644 packages/format-library/.npmrc create mode 100644 packages/format-library/README.md create mode 100644 packages/format-library/package.json create mode 100644 packages/format-library/src/bold/index.js create mode 100644 packages/format-library/src/code/index.js create mode 100644 packages/format-library/src/image/index.js create mode 100644 packages/format-library/src/index.js create mode 100644 packages/format-library/src/italic/index.js create mode 100644 packages/format-library/src/link/index.js rename packages/{editor/src/components/rich-text/format-toolbar/link-container.js => format-library/src/link/inline.js} (61%) rename packages/{editor/src/components/rich-text/format-toolbar => format-library/src/link}/positioned-at-selection.js (100%) create mode 100644 packages/format-library/src/link/style.scss create mode 100644 packages/format-library/src/strikethrough/index.js create mode 100644 packages/format-library/src/style.scss create mode 100644 packages/rich-text/src/get-format-type.js create mode 100644 packages/rich-text/src/get-format-types.js create mode 100644 packages/rich-text/src/insert-object.js create mode 100644 packages/rich-text/src/register-format-type.js create mode 100644 packages/rich-text/src/special-characters.js create mode 100644 packages/rich-text/src/store/actions.js create mode 100644 packages/rich-text/src/store/index.js create mode 100644 packages/rich-text/src/store/reducer.js create mode 100644 packages/rich-text/src/store/selectors.js create mode 100644 packages/rich-text/src/store/test/reducer.js create mode 100644 packages/rich-text/src/toggle-format.js create mode 100644 packages/rich-text/src/unregister-format-type.js diff --git a/docs/manifest.json b/docs/manifest.json index 7c2ca6b45ee091..13c0c5adb08693 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -401,6 +401,12 @@ "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/escape-html/README.md", "parent": "packages" }, + { + "title": "@wordpress/format-library", + "slug": "packages-format-library", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/format-library/README.md", + "parent": "packages" + }, { "title": "@wordpress/hooks", "slug": "packages-hooks", diff --git a/lib/client-assets.php b/lib/client-assets.php index 5724299ce24740..96f83331d341d3 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -418,7 +418,12 @@ function gutenberg_register_scripts_and_styles() { gutenberg_override_script( 'wp-rich-text', gutenberg_url( 'build/rich-text/index.js' ), - array( 'wp-polyfill', 'wp-escape-html', 'lodash' ), + array( + 'lodash', + 'wp-polyfill', + 'wp-data', + 'wp-escape-html', + ), filemtime( gutenberg_dir_path() . 'build/rich-text/index.js' ), true ); @@ -502,6 +507,23 @@ function gutenberg_register_scripts_and_styles() { filemtime( gutenberg_dir_path() . 'build/block-library/index.js' ), true ); + gutenberg_override_script( + 'wp-format-library', + gutenberg_url( 'build/format-library/index.js' ), + array( + 'wp-components', + 'wp-dom', + 'wp-editor', + 'wp-element', + 'wp-i18n', + 'wp-keycodes', + 'wp-polyfill', + 'wp-rich-text', + 'wp-url', + ), + filemtime( gutenberg_dir_path() . 'build/format-library/index.js' ), + true + ); gutenberg_override_script( 'wp-nux', gutenberg_url( 'build/nux/index.js' ), @@ -771,6 +793,14 @@ function gutenberg_register_scripts_and_styles() { ); wp_style_add_data( 'wp-block-library', 'rtl', 'replace' ); + gutenberg_override_style( + 'wp-format-library', + gutenberg_url( 'build/format-library/style.css' ), + array(), + filemtime( gutenberg_dir_path() . 'build/format-library/style.css' ) + ); + wp_style_add_data( 'wp-format-library', 'rtl', 'replace' ); + gutenberg_override_style( 'wp-edit-blocks', gutenberg_url( 'build/block-library/editor.css' ), @@ -1323,6 +1353,8 @@ function gutenberg_editor_scripts_and_styles( $hook ) { add_filter( 'user_can_richedit', '__return_true' ); wp_enqueue_script( 'wp-edit-post' ); + wp_enqueue_script( 'wp-format-library' ); + wp_enqueue_style( 'wp-format-library' ); global $post; diff --git a/package-lock.json b/package-lock.json index be04942b439815..736ffb3f79c7f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2234,6 +2234,7 @@ "@wordpress/deprecated": "file:packages/deprecated", "@wordpress/editor": "file:packages/editor", "@wordpress/element": "file:packages/element", + "@wordpress/format-library": "file:packages/format-library", "@wordpress/hooks": "file:packages/hooks", "@wordpress/i18n": "file:packages/i18n", "@wordpress/keycodes": "file:packages/keycodes", @@ -2305,6 +2306,20 @@ "@babel/runtime": "^7.0.0" } }, + "@wordpress/format-library": { + "version": "file:packages/format-library", + "requires": { + "@babel/runtime": "^7.0.0", + "@wordpress/components": "file:packages/components", + "@wordpress/dom": "file:packages/dom", + "@wordpress/editor": "file:packages/editor", + "@wordpress/element": "file:packages/element", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/keycodes": "file:packages/keycodes", + "@wordpress/rich-text": "file:packages/rich-text", + "@wordpress/url": "file:packages/url" + } + }, "@wordpress/hooks": { "version": "file:packages/hooks", "requires": { @@ -2416,8 +2431,10 @@ "version": "file:packages/rich-text", "requires": { "@babel/runtime": "^7.0.0", + "@wordpress/data": "file:packages/data", "@wordpress/escape-html": "file:packages/escape-html", - "lodash": "^4.17.10" + "lodash": "^4.17.10", + "rememo": "^3.0.0" } }, "@wordpress/scripts": { diff --git a/package.json b/package.json index ea9c4215de5da3..b19fb2ed1784cc 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@wordpress/editor": "file:packages/editor", "@wordpress/element": "file:packages/element", "@wordpress/escape-html": "file:packages/escape-html", + "@wordpress/format-library": "file:packages/format-library", "@wordpress/hooks": "file:packages/hooks", "@wordpress/html-entities": "file:packages/html-entities", "@wordpress/i18n": "file:packages/i18n", diff --git a/packages/components/src/index.js b/packages/components/src/index.js index fcaf2b981c9219..a1a36393346d40 100644 --- a/packages/components/src/index.js +++ b/packages/components/src/index.js @@ -55,6 +55,7 @@ export { default as TextControl } from './text-control'; export { default as TextareaControl } from './textarea-control'; export { default as ToggleControl } from './toggle-control'; export { default as Toolbar } from './toolbar'; +export { default as ToolbarButton } from './toolbar-button'; export { default as Tooltip } from './tooltip'; export { default as TreeSelect } from './tree-select'; export { createSlotFill, Slot, Fill, Provider as SlotFillProvider } from './slot-fill'; diff --git a/packages/components/src/style.scss b/packages/components/src/style.scss index 751eae5aaa3c1d..c3e8b38e7a7efd 100644 --- a/packages/components/src/style.scss +++ b/packages/components/src/style.scss @@ -38,4 +38,5 @@ @import "./textarea-control/style.scss"; @import "./toggle-control/style.scss"; @import "./toolbar/style.scss"; +@import "./toolbar-button/style.scss"; @import "./tooltip/style.scss"; diff --git a/packages/components/src/toolbar-button/index.js b/packages/components/src/toolbar-button/index.js new file mode 100644 index 00000000000000..8c2374b0284d1d --- /dev/null +++ b/packages/components/src/toolbar-button/index.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import IconButton from '../icon-button'; +import ToolbarButtonContainer from './toolbar-button-container'; + +function ToolbarButton( { + containerClassName, + icon, + title, + shortcut, + subscript, + onClick, + className, + isActive, + isDisabled, + extraProps, + children, +} ) { + return ( + + { + event.stopPropagation(); + onClick(); + } } + className={ classnames( + 'components-toolbar__control', + className, + { 'is-active': isActive } + ) } + aria-pressed={ isActive } + disabled={ isDisabled } + { ...extraProps } + /> + { children } + + ); +} + +export default ToolbarButton; diff --git a/packages/components/src/toolbar-button/style.scss b/packages/components/src/toolbar-button/style.scss new file mode 100644 index 00000000000000..65f3c0e56c11a5 --- /dev/null +++ b/packages/components/src/toolbar-button/style.scss @@ -0,0 +1,77 @@ +.components-toolbar__control.components-button { + display: inline-flex; + align-items: flex-end; + margin: 0; + padding: 3px; + outline: none; + cursor: pointer; + position: relative; + width: $icon-button-size; + height: $icon-button-size; + + // Unset icon button styles + &:active, + &:not([aria-disabled="true"]):hover, + &:not([aria-disabled="true"]):focus { + outline: none; + box-shadow: none; + background: none; + border: none; + } + + // Disabled + &:disabled { + cursor: default; + } + + & > svg { + padding: 5px; + border-radius: $radius-round-rectangle; + height: 30px; + width: 30px; + } + + // Subscript for numbered icon buttons, like headings + &[data-subscript] svg { + padding: 5px 10px 5px 0; + } + + &[data-subscript]::after { + content: attr(data-subscript); + font-family: $default-font; + font-size: $default-font-size; + font-weight: 600; + line-height: 12px; + position: absolute; + right: 8px; + bottom: 10px; + } + + // Assign hover style to child element, not the button itself + &:not(:disabled):not([aria-disabled="true"]):hover { + box-shadow: none; + } + + &:not(:disabled).is-active > svg, + &:not(:disabled):hover > svg { + @include formatting-button-style__hover; + } + + // Active & toggled style + &:not(:disabled).is-active > svg { + @include formatting-button-style__active; + } + + &:not(:disabled).is-active[data-subscript]::after { + color: $white; + } + + // Focus style + &:not(:disabled):focus > svg { + @include formatting-button-style__focus; + } +} + +.components-toolbar__control .dashicon { + display: block; +} diff --git a/packages/components/src/toolbar/toolbar-button-container.js b/packages/components/src/toolbar-button/toolbar-button-container.js similarity index 100% rename from packages/components/src/toolbar/toolbar-button-container.js rename to packages/components/src/toolbar-button/toolbar-button-container.js diff --git a/packages/components/src/toolbar/toolbar-button-container.native.js b/packages/components/src/toolbar-button/toolbar-button-container.native.js similarity index 100% rename from packages/components/src/toolbar/toolbar-button-container.native.js rename to packages/components/src/toolbar-button/toolbar-button-container.native.js diff --git a/packages/components/src/toolbar/index.js b/packages/components/src/toolbar/index.js index 548c5fb4affffc..15fc1972b44979 100644 --- a/packages/components/src/toolbar/index.js +++ b/packages/components/src/toolbar/index.js @@ -7,10 +7,9 @@ import { flatMap } from 'lodash'; /** * Internal dependencies */ -import IconButton from '../icon-button'; +import ToolbarButton from '../toolbar-button'; import DropdownMenu from '../dropdown-menu'; import ToolbarContainer from './toolbar-container'; -import ToolbarButtonContainer from './toolbar-button-container'; /** * Renders a toolbar with controls. @@ -71,28 +70,11 @@ function Toolbar( { controls = [], children, className, isCollapsed, icon, label { flatMap( controlSets, ( controlSet, indexOfSet ) => ( controlSet.map( ( control, indexOfControl ) => ( - 0 && indexOfControl === 0 ? 'has-left-divider' : null } - > - { - event.stopPropagation(); - control.onClick(); - } } - className={ classnames( 'components-toolbar__control', control.className, { - 'is-active': control.isActive, - } ) } - aria-pressed={ control.isActive } - disabled={ control.isDisabled } - { ...control.extraProps } - /> - { control.children } - + containerClassName={ indexOfSet > 0 && indexOfControl === 0 ? 'has-left-divider' : null } + { ...control } + /> ) ) ) ) } { children } diff --git a/packages/components/src/toolbar/style.scss b/packages/components/src/toolbar/style.scss index 13d609e3d65584..1356b9d83d4afd 100644 --- a/packages/components/src/toolbar/style.scss +++ b/packages/components/src/toolbar/style.scss @@ -39,81 +39,3 @@ div.components-toolbar { } } } - -.components-toolbar__control.components-button { - display: inline-flex; - align-items: flex-end; - margin: 0; - padding: 3px; - outline: none; - cursor: pointer; - position: relative; - width: $icon-button-size; - height: $icon-button-size; - - // Unset icon button styles - &:active, - &:not([aria-disabled="true"]):hover, - &:not([aria-disabled="true"]):focus { - outline: none; - box-shadow: none; - background: none; - border: none; - } - - // Disabled - &:disabled { - cursor: default; - } - - & > svg { - padding: 5px; - border-radius: $radius-round-rectangle; - height: 30px; - width: 30px; - } - - // Subscript for numbered icon buttons, like headings - &[data-subscript] svg { - padding: 5px 10px 5px 0; - } - - &[data-subscript]::after { - content: attr(data-subscript); - font-family: $default-font; - font-size: $default-font-size; - font-weight: 600; - line-height: 12px; - position: absolute; - right: 8px; - bottom: 10px; - } - - // Assign hover style to child element, not the button itself - &:not(:disabled):not([aria-disabled="true"]):hover { - box-shadow: none; - } - - &:not(:disabled).is-active > svg, - &:not(:disabled):hover > svg { - @include formatting-button-style__hover; - } - - // Active & toggled style - &:not(:disabled).is-active > svg { - @include formatting-button-style__active; - } - - &:not(:disabled).is-active[data-subscript]::after { - color: $white; - } - - // Focus style - &:not(:disabled):focus > svg { - @include formatting-button-style__focus; - } -} - -.components-toolbar__control .dashicon { - display: block; -} diff --git a/packages/components/src/toolbar/test/index.js b/packages/components/src/toolbar/test/index.js index 08a9def41e5d50..6abcad9c843440 100644 --- a/packages/components/src/toolbar/test/index.js +++ b/packages/components/src/toolbar/test/index.js @@ -20,7 +20,7 @@ describe( 'Toolbar', () => { expect( toolbar.type() ).toBeNull(); } ); - it( 'should render a list of controls with IconButtons', () => { + it( 'should render a list of controls with ToolbarButtons', () => { const clickHandler = ( event ) => event; const controls = [ { @@ -32,17 +32,18 @@ describe( 'Toolbar', () => { }, ]; const toolbar = shallow( ); - const listItem = toolbar.find( 'IconButton' ); + const listItem = toolbar.find( 'ToolbarButton' ); expect( listItem.props() ).toMatchObject( { + containerClassName: null, icon: 'wordpress', - label: 'WordPress', - 'data-subscript': 'wp', - 'aria-pressed': false, - className: 'components-toolbar__control', + title: 'WordPress', + subscript: 'wp', + onClick: clickHandler, + isActive: false, } ); } ); - it( 'should render a list of controls with IconButtons and active control', () => { + it( 'should render a list of controls with ToolbarButtons and active control', () => { const clickHandler = ( event ) => event; const controls = [ { @@ -54,10 +55,14 @@ describe( 'Toolbar', () => { }, ]; const toolbar = shallow( ); - const listItem = toolbar.find( 'IconButton' ); + const listItem = toolbar.find( 'ToolbarButton' ); expect( listItem.props() ).toMatchObject( { - 'aria-pressed': true, - className: 'components-toolbar__control is-active', + containerClassName: null, + icon: 'wordpress', + title: 'WordPress', + subscript: 'wp', + onClick: clickHandler, + isActive: true, } ); } ); @@ -79,8 +84,8 @@ describe( 'Toolbar', () => { const toolbar = shallow( ); expect( toolbar.children() ).toHaveLength( 2 ); - expect( toolbar.childAt( 0 ).hasClass( 'has-left-divider' ) ).toBe( false ); - expect( toolbar.childAt( 1 ).hasClass( 'has-left-divider' ) ).toBe( true ); + expect( toolbar.childAt( 0 ).prop( 'containerClassName' ) ).toBeNull(); + expect( toolbar.childAt( 1 ).prop( 'containerClassName' ) ).toBe( 'has-left-divider' ); } ); it( 'should call the clickHandler on click.', () => { @@ -96,10 +101,10 @@ describe( 'Toolbar', () => { }, ]; const toolbar = shallow( ); - const listItem = toolbar.find( 'IconButton' ); + const listItem = toolbar.find( 'ToolbarButton' ); listItem.simulate( 'click', event ); expect( clickHandler ).toHaveBeenCalledTimes( 1 ); - expect( clickHandler ).toHaveBeenCalledWith(); + expect( clickHandler ).toHaveBeenCalledWith( event ); } ); } ); } ); diff --git a/packages/edit-post/package.json b/packages/edit-post/package.json index 0b5bd5d6d68f89..583cb9e0ce5271 100644 --- a/packages/edit-post/package.json +++ b/packages/edit-post/package.json @@ -31,6 +31,7 @@ "@wordpress/deprecated": "file:../deprecated", "@wordpress/editor": "file:../editor", "@wordpress/element": "file:../element", + "@wordpress/format-library": "file:../format-library", "@wordpress/hooks": "file:../hooks", "@wordpress/i18n": "file:../i18n", "@wordpress/keycodes": "file:../keycodes", diff --git a/packages/editor/src/components/block-types-list/index.js b/packages/editor/src/components/block-types-list/index.js index d27d0d96ea61ac..16ec272453a940 100644 --- a/packages/editor/src/components/block-types-list/index.js +++ b/packages/editor/src/components/block-types-list/index.js @@ -1,86 +1,45 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; -import { noop } from 'lodash'; - /** * WordPress dependencies */ -import { Component } from '@wordpress/element'; import { getBlockMenuDefaultClassName } from '@wordpress/blocks'; /** * Internal dependencies */ -import BlockIcon from '../block-icon'; - -class BlockTypesList extends Component { - render() { - const { items, onSelect, onHover = noop } = this.props; - - return ( - /* - * Disable reason: The `list` ARIA role is redundant but - * Safari+VoiceOver won't announce the list otherwise. - */ - /* eslint-disable jsx-a11y/no-redundant-roles */ -
    - { items.map( ( item ) => { - const itemIconStyle = item.icon ? { - backgroundColor: item.icon.background, - color: item.icon.foreground, - } : {}; - const itemIconStackStyle = item.icon && item.icon.shadowColor ? { - backgroundColor: item.icon.shadowColor, - } : {}; - return ( -
  • - -
  • - ); - } ) } -
- /* eslint-enable jsx-a11y/no-redundant-roles */ - ); - } +function BlockTypesList( { items, onSelect, onHover = () => {}, children } ) { + return ( + /* + * Disable reason: The `list` ARIA role is redundant but + * Safari+VoiceOver won't announce the list otherwise. + */ + /* eslint-disable jsx-a11y/no-redundant-roles */ +
    + { items && items.map( ( item ) => + { + onSelect( item ); + onHover( null ); + } } + onFocus={ () => onHover( item ) } + onMouseEnter={ () => onHover( item ) } + onMouseLeave={ () => onHover( null ) } + onBlur={ () => onHover( null ) } + isDisabled={ item.isDisabled } + title={ item.title } + /> + ) } + { children } +
+ /* eslint-enable jsx-a11y/no-redundant-roles */ + ); } export default BlockTypesList; diff --git a/packages/editor/src/components/block-types-list/style.scss b/packages/editor/src/components/block-types-list/style.scss index f0ca494b7f8a34..b6ab6afd92516a 100644 --- a/packages/editor/src/components/block-types-list/style.scss +++ b/packages/editor/src/components/block-types-list/style.scss @@ -5,118 +5,3 @@ display: flex; flex-wrap: wrap; } - -.editor-block-types-list__list-item { - display: block; - width: 33.33%; - padding: 0 4px; - margin: 0 0 12px; -} - -.editor-block-types-list__item { - display: flex; - flex-direction: column; - width: 100%; - font-size: $default-font-size; - color: $dark-gray-700; - padding: 0; - align-items: stretch; - justify-content: center; - cursor: pointer; - background: transparent; - word-break: break-word; - border-radius: $radius-round-rectangle; - border: $border-width solid transparent; - transition: all 0.05s ease-in-out; - position: relative; - - &:disabled { - @include block-style__disabled(); - } - - &:not(:disabled) { - - // Show the hover background in a pseudo selector so it can be below "stacked" icons, like Columns. - &:hover::before { - content: ""; - display: block; - @include block-style__hover(); - position: absolute; - z-index: -1; - border-radius: $radius-round-rectangle; - top: 0; - right: 0; - bottom: 0; - left: 0; - } - - &:hover { - .editor-block-types-list__item-icon, - .editor-block-types-list__item-title { - color: currentColor; - } - } - - &:active, - &.is-active, - &:focus { - position: relative; - - // Show the focus style in the icon inside instead. - outline: none; - @include block-style__focus-active(); - - .editor-block-types-list__item-icon, - .editor-block-types-list__item-title { - color: currentColor; - } - } - } -} - -.editor-block-types-list__item-icon { - padding: 12px 20px; - border-radius: $radius-round-rectangle; - color: $dark-gray-500; - transition: all 0.05s ease-in-out; - - .editor-block-icon { - margin-left: auto; - margin-right: auto; - } - - svg { - transition: all 0.15s ease-out; - } -} - -.editor-block-types-list__item-title { - padding: 4px 2px 8px; -} - -.editor-block-types-list__item-has-children { - .editor-block-types-list__item-icon { - background: $white; - margin-right: 3px; - margin-bottom: 6px; - padding: 9px 20px 9px; - position: relative; - top: -2px; - left: -2px; - box-shadow: 0 0 0 1px $light-gray-500; - } - - // Show a "stacked card" below an item that has children. - .editor-block-types-list__item-icon-stack { - display: block; - background: $white; - box-shadow: 0 0 0 1px $light-gray-500; - width: 100%; - height: 100%; - position: absolute; - z-index: -1; // Show below the card as a shadow - bottom: -6px; - right: -6px; - border-radius: 4px; - } -} diff --git a/packages/editor/src/components/inserter-list-item/index.js b/packages/editor/src/components/inserter-list-item/index.js new file mode 100644 index 00000000000000..b5b3a508ff3765 --- /dev/null +++ b/packages/editor/src/components/inserter-list-item/index.js @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { normalizeIconObject } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import BlockIcon from '../block-icon'; + +function InserterListItem( { + icon, + hasChildBlocksWithInserterSupport, + onClick, + isDisabled, + title, + className, + ...props +} ) { + icon = normalizeIconObject( icon ); + + const itemIconStyle = icon ? { + backgroundColor: icon.background, + color: icon.foreground, + } : {}; + const itemIconStackStyle = icon && icon.shadowColor ? { + backgroundColor: icon.shadowColor, + } : {}; + + return ( +
  • + +
  • + ); +} + +export default InserterListItem; diff --git a/packages/editor/src/components/inserter-list-item/style.scss b/packages/editor/src/components/inserter-list-item/style.scss new file mode 100644 index 00000000000000..7a3b97c05c9c9c --- /dev/null +++ b/packages/editor/src/components/inserter-list-item/style.scss @@ -0,0 +1,114 @@ +.editor-block-types-list__list-item { + display: block; + width: 33.33%; + padding: 0 4px; + margin: 0 0 12px; +} + +.editor-block-types-list__item { + display: flex; + flex-direction: column; + width: 100%; + font-size: $default-font-size; + color: $dark-gray-700; + padding: 0; + align-items: stretch; + justify-content: center; + cursor: pointer; + background: transparent; + word-break: break-word; + border-radius: $radius-round-rectangle; + border: $border-width solid transparent; + transition: all 0.05s ease-in-out; + position: relative; + + &:disabled { + @include block-style__disabled(); + } + + &:not(:disabled) { + + // Show the hover background in a pseudo selector so it can be below "stacked" icons, like Columns. + &:hover::before { + content: ""; + display: block; + @include block-style__hover(); + position: absolute; + z-index: -1; + border-radius: $radius-round-rectangle; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + + &:hover { + .editor-block-types-list__item-icon, + .editor-block-types-list__item-title { + color: currentColor; + } + } + + &:active, + &.is-active, + &:focus { + position: relative; + + // Show the focus style in the icon inside instead. + outline: none; + @include block-style__focus-active(); + + .editor-block-types-list__item-icon, + .editor-block-types-list__item-title { + color: currentColor; + } + } + } +} + +.editor-block-types-list__item-icon { + padding: 12px 20px; + border-radius: $radius-round-rectangle; + color: $dark-gray-500; + transition: all 0.05s ease-in-out; + + .editor-block-icon { + margin-left: auto; + margin-right: auto; + } + + svg { + transition: all 0.15s ease-out; + } +} + +.editor-block-types-list__item-title { + padding: 4px 2px 8px; +} + +.editor-block-types-list__item-has-children { + .editor-block-types-list__item-icon { + background: $white; + margin-right: 3px; + margin-bottom: 6px; + padding: 9px 20px 9px; + position: relative; + top: -2px; + left: -2px; + box-shadow: 0 0 0 1px $light-gray-500; + } + + // Show a "stacked card" below an item that has children. + .editor-block-types-list__item-icon-stack { + display: block; + background: $white; + box-shadow: 0 0 0 1px $light-gray-500; + width: 100%; + height: 100%; + position: absolute; + z-index: -1; // Show below the card as a shadow + bottom: -6px; + right: -6px; + border-radius: 4px; + } +} diff --git a/packages/editor/src/components/inserter/index.js b/packages/editor/src/components/inserter/index.js index 73a971a7de5c73..720198626a85c9 100644 --- a/packages/editor/src/components/inserter/index.js +++ b/packages/editor/src/components/inserter/index.js @@ -13,8 +13,6 @@ import { compose } from '@wordpress/compose'; */ import InserterMenu from './menu'; -export { default as InserterResultsPortal } from './results-portal'; - const defaultRenderToggle = ( { onToggle, disabled, isOpen } ) => ( { + return ( + + { ( fills ) => ! isEmpty( fills ) && ( + + + { fills } + + + ) } + + ); +}; + +export default InserterInlineElements; diff --git a/packages/editor/src/components/inserter/menu.js b/packages/editor/src/components/inserter/menu.js index 95ba3e12494e5f..1a30907b755391 100644 --- a/packages/editor/src/components/inserter/menu.js +++ b/packages/editor/src/components/inserter/menu.js @@ -33,7 +33,7 @@ import { withInstanceId, compose, withSafeTimeout } from '@wordpress/compose'; import BlockPreview from '../block-preview'; import BlockTypesList from '../block-types-list'; import ChildBlocks from './child-blocks'; -import InserterResultsPortal from './results-portal'; +import InserterInlineElements from './inline-elements'; const MAX_SUGGESTED_ITEMS = 9; @@ -269,7 +269,7 @@ export class InserterMenu extends Component { } - + { map( getCategories(), ( category ) => { const categoryItems = itemsPerCategory[ category.slug ]; diff --git a/packages/editor/src/components/inserter/results-portal.js b/packages/editor/src/components/inserter/results-portal.js deleted file mode 100644 index 075bd2109aacf1..00000000000000 --- a/packages/editor/src/components/inserter/results-portal.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * WordPress dependencies - */ -import { createSlotFill, PanelBody } from '@wordpress/components'; - -/** - * Internal dependencies - */ -import BlockTypesList from '../block-types-list'; -import { searchItems } from './menu'; - -const { Fill, Slot } = createSlotFill( 'InserterResultsPortal' ); - -const InserterResultsPortal = ( { items, title, onSelect, onHover, ...props } ) => { - return ( - - { ( { filterValue } ) => { - const filteredItems = searchItems( items, filterValue ); - - if ( ! filteredItems.length ) { - return null; - } - - return ( - - - - ); - } } - - ); -}; - -InserterResultsPortal.Slot = Slot; - -export default InserterResultsPortal; diff --git a/packages/editor/src/components/inserter/style.scss b/packages/editor/src/components/inserter/style.scss index ed8bff278e5b48..5423cae2cc32cd 100644 --- a/packages/editor/src/components/inserter/style.scss +++ b/packages/editor/src/components/inserter/style.scss @@ -56,7 +56,7 @@ $block-inserter-search-height: 38px; } } -.editor-inserter__results-portal { +.editor-inserter__inline-elements { margin-top: -1px; } diff --git a/packages/editor/src/components/rich-text/core-tokens/image/index.js b/packages/editor/src/components/rich-text/core-tokens/image/index.js deleted file mode 100644 index 01e73d8847138f..00000000000000 --- a/packages/editor/src/components/rich-text/core-tokens/image/index.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { SVG, Path } from '@wordpress/components'; - -/** - * Internal dependencies - */ -import MediaUpload from '../../../media-upload'; - -export const name = 'core/image'; - -const ALLOWED_MEDIA_TYPES = [ 'image' ]; - -export const settings = { - id: 'image', - - title: __( 'Inline Image' ), - - type: 'image', - - icon: , - - edit( { onSave } ) { - return ( - onSave( media ) } - onClose={ () => onSave( null ) } - render={ ( { open } ) => { - open(); - return null; - } } - /> - ); - }, - - save( { id, url, alt, width } ) { - return ( - { - ); - }, -}; diff --git a/packages/editor/src/components/rich-text/core-tokens/image/style.scss b/packages/editor/src/components/rich-text/core-tokens/image/style.scss deleted file mode 100644 index 0d5a879e52ce2f..00000000000000 --- a/packages/editor/src/components/rich-text/core-tokens/image/style.scss +++ /dev/null @@ -1,11 +0,0 @@ -.mce-content-body div.mce-resizehandle { - border-radius: 50%; - border: 2px solid $white; - width: 15px !important; - height: 15px !important; - position: absolute; - background: theme(primary); - padding: 0 3px 3px 0; - box-sizing: border-box; - cursor: se-resize; -} diff --git a/packages/editor/src/components/rich-text/core-tokens/index.js b/packages/editor/src/components/rich-text/core-tokens/index.js deleted file mode 100644 index ecb16927589f6d..00000000000000 --- a/packages/editor/src/components/rich-text/core-tokens/index.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Internal dependencies - */ -import * as image from './image'; - -export { image }; diff --git a/packages/editor/src/components/rich-text/format-edit.js b/packages/editor/src/components/rich-text/format-edit.js new file mode 100644 index 00000000000000..20d3c9258025fc --- /dev/null +++ b/packages/editor/src/components/rich-text/format-edit.js @@ -0,0 +1,103 @@ +/** + * WordPress dependencies + */ +import { Component, Fragment } from '@wordpress/element'; +import { getActiveFormat, getFormatTypes } from '@wordpress/rich-text'; +import { Fill, KeyboardShortcuts, ToolbarButton } from '@wordpress/components'; +import { rawShortcut } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import InserterListItem from '../inserter-list-item'; +import { normalizeTerm } from '../inserter/menu'; + +function isResult( { title, keywords = [] }, filterValue ) { + const normalizedSearchTerm = normalizeTerm( filterValue ); + const matchSearch = ( string ) => normalizeTerm( string ).indexOf( normalizedSearchTerm ) !== -1; + return matchSearch( title ) || keywords.some( matchSearch ); +} + +function FillToolbarButton( { name, ...props } ) { + return ( + + + + ); +} + +function FillInserterListItem( props ) { + return ( + + { ( { filterValue } ) => { + if ( filterValue && ! isResult( props, filterValue ) ) { + return null; + } + + return ; + } } + + ); +} + +class Shortcut extends Component { + constructor() { + super( ...arguments ); + + this.onUse = this.onUse.bind( this ); + } + + onUse() { + this.props.onUse(); + return false; + } + + render() { + const { character, type } = this.props; + + return ( + + ); + } +} + +const FormatEdit = ( { onChange, value } ) => { + return ( + + { getFormatTypes().map( ( { name, edit: Edit, keywords } ) => { + if ( ! Edit ) { + return null; + } + + const activeFormat = getActiveFormat( value, name ); + const isActive = activeFormat !== undefined; + const activeAttributes = isActive ? activeFormat.attributes || {} : {}; + + return ( + + + } + Shortcut={ Shortcut } + /> + ); + } ) } + + ); +}; + +export default FormatEdit; diff --git a/packages/editor/src/components/rich-text/format-toolbar/index.js b/packages/editor/src/components/rich-text/format-toolbar/index.js index a4a4f4f1ed4b68..5eda88f589edf3 100644 --- a/packages/editor/src/components/rich-text/format-toolbar/index.js +++ b/packages/editor/src/components/rich-text/format-toolbar/index.js @@ -1,162 +1,19 @@ /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; -import { - Toolbar, - withSpokenMessages, -} from '@wordpress/components'; -import { rawShortcut } from '@wordpress/keycodes'; -import { Component } from '@wordpress/element'; -import { - applyFormat, - removeFormat, - getActiveFormat, - getTextContent, - slice, -} from '@wordpress/rich-text'; -import { isURL } from '@wordpress/url'; -/** - * Internal dependencies - */ -import { FORMATTING_CONTROLS } from '../formatting-controls'; -import LinkContainer from './link-container'; -import ToolbarContainer from './toolbar-container'; - -class FormatToolbar extends Component { - constructor( { editor } ) { - super( ...arguments ); - - this.removeLink = this.removeLink.bind( this ); - this.addLink = this.addLink.bind( this ); - this.stopAddingLink = this.stopAddingLink.bind( this ); - this.applyFormat = this.applyFormat.bind( this ); - this.removeFormat = this.removeFormat.bind( this ); - this.getActiveFormat = this.getActiveFormat.bind( this ); - this.toggleFormat = this.toggleFormat.bind( this ); - - this.state = { - addingLink: false, - }; - - if ( editor ) { - editor.shortcuts.add( rawShortcut.primary( 'k' ), '', this.addLink ); - editor.shortcuts.add( rawShortcut.access( 'a' ), '', this.addLink ); - editor.shortcuts.add( rawShortcut.access( 's' ), '', this.removeLink ); - editor.shortcuts.add( rawShortcut.access( 'd' ), '', () => this.toggleFormat( { type: 'del' } ) ); - editor.shortcuts.add( rawShortcut.access( 'x' ), '', () => this.toggleFormat( { type: 'code' } ) ); - } - } - - removeLink() { - this.removeFormat( 'a' ); - this.props.speak( __( 'Link removed.' ), 'assertive' ); - } - - addLink() { - const text = getTextContent( slice( this.props.record ) ); - - if ( text && isURL( text ) ) { - this.applyFormat( { - type: 'a', - attributes: { - href: text, - }, - } ); - } else { - this.setState( { addingLink: true } ); - } - } - - stopAddingLink() { - this.setState( { addingLink: false } ); - } - - /** - * Apply a format with the current value and selection. - * - * @param {Object} format The format to apply. - */ - applyFormat( format ) { - this.props.onChange( applyFormat( this.props.record, format ) ); - } - - /** - * Remove a format from the current value with the current selection. - * - * @param {string} formatType The type of format to remove. - */ - removeFormat( formatType ) { - this.props.onChange( removeFormat( this.props.record, formatType ) ); - } - - /** - * Get the current format based on the selection - * - * @param {string} formatType The type of format to check. - * - * @return {boolean} Whether the format is active or not. - */ - getActiveFormat( formatType ) { - return getActiveFormat( this.props.record, formatType ); - } - - /** - * Toggle a format based on the selection. - * - * @param {Object} format The format to toggle. - */ - toggleFormat( format ) { - if ( this.getActiveFormat( format.type ) ) { - this.removeFormat( format.type ); - } else { - this.applyFormat( format ); - } - } - - render() { - const link = this.getActiveFormat( 'a' ); - const toolbarControls = FORMATTING_CONTROLS - .filter( ( control ) => this.props.enabledControls.indexOf( control.format ) !== -1 ) - .map( ( control ) => { - if ( control.format === 'link' ) { - const linkIsActive = link !== undefined; - - return { - ...control, - shortcut: linkIsActive ? control.activeShortcut : control.shortcut, - icon: linkIsActive ? 'editor-unlink' : 'admin-links', // TODO: Need proper unlink icon - title: linkIsActive ? __( 'Unlink' ) : __( 'Link' ), - onClick: linkIsActive ? this.removeLink : this.addLink, - isActive: !! linkIsActive, - }; - } - - return { - ...control, - onClick: () => this.toggleFormat( { type: control.selector } ), - isActive: this.getActiveFormat( control.selector ) !== undefined, - }; - } ); - - return ( - - - - - ); - } -} - -export default withSpokenMessages( FormatToolbar ); +import { Toolbar, Slot } from '@wordpress/components'; + +const FormatToolbar = ( { controls } ) => { + return ( +
    + + { controls.map( ( format ) => + + ) } + +
    + ); +}; + +export default FormatToolbar; diff --git a/packages/editor/src/components/rich-text/format-toolbar/style.scss b/packages/editor/src/components/rich-text/format-toolbar/style.scss index 5ae6291e3262f6..2b6fa1e5c07b5d 100644 --- a/packages/editor/src/components/rich-text/format-toolbar/style.scss +++ b/packages/editor/src/components/rich-text/format-toolbar/style.scss @@ -7,18 +7,3 @@ position: absolute; transform: translateX(-50%); } - -.editor-format-toolbar__link-container-content { - display: flex; -} - -.editor-format-toolbar__link-container-value { - margin: $grid-size - $border-width; - flex-grow: 1; - flex-shrink: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 150px; - max-width: 500px; -} diff --git a/packages/editor/src/components/rich-text/format-toolbar/toolbar-container.js b/packages/editor/src/components/rich-text/format-toolbar/toolbar-container.js deleted file mode 100644 index effed4d9271811..00000000000000 --- a/packages/editor/src/components/rich-text/format-toolbar/toolbar-container.js +++ /dev/null @@ -1,8 +0,0 @@ -const ToolbarContainer = ( props ) => ( -
    - { props.children } -
    -); -export default ToolbarContainer; diff --git a/packages/editor/src/components/rich-text/formatting-controls.js b/packages/editor/src/components/rich-text/formatting-controls.js deleted file mode 100644 index 2699a17fbbe7ca..00000000000000 --- a/packages/editor/src/components/rich-text/formatting-controls.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { displayShortcut } from '@wordpress/keycodes'; - -export const FORMATTING_CONTROLS = [ - { - icon: 'editor-bold', - title: __( 'Bold' ), - shortcut: displayShortcut.primary( 'b' ), - format: 'bold', - selector: 'strong', - }, - { - icon: 'editor-italic', - title: __( 'Italic' ), - shortcut: displayShortcut.primary( 'i' ), - format: 'italic', - selector: 'em', - }, - { - icon: 'admin-links', - title: __( 'Link' ), - shortcut: displayShortcut.primary( 'k' ), - activeShortcut: displayShortcut.access( 's' ), - format: 'link', - selector: 'a', - }, - { - icon: 'editor-strikethrough', - title: __( 'Strikethrough' ), - shortcut: displayShortcut.access( 'd' ), - format: 'strikethrough', - selector: 'del', - }, -]; diff --git a/packages/editor/src/components/rich-text/index.js b/packages/editor/src/components/rich-text/index.js index 582c75c0a1e415..20cf3555bb8bda 100644 --- a/packages/editor/src/components/rich-text/index.js +++ b/packages/editor/src/components/rich-text/index.js @@ -23,7 +23,6 @@ import { } from '@wordpress/dom'; import { createBlobURL } from '@wordpress/blob'; import { BACKSPACE, DELETE, ENTER, LEFT, RIGHT, rawShortcut } from '@wordpress/keycodes'; -import { Slot } from '@wordpress/components'; import { withDispatch, withSelect } from '@wordpress/data'; import { rawHandler, children, getBlockTransforms, findTransform } from '@wordpress/blocks'; import { withInstanceId, withSafeTimeout, compose } from '@wordpress/compose'; @@ -47,13 +46,12 @@ import { decodeEntities } from '@wordpress/html-entities'; */ import Autocomplete from '../autocomplete'; import BlockFormatControls from '../block-format-controls'; -import { FORMATTING_CONTROLS } from './formatting-controls'; +import FormatEdit from './format-edit'; import FormatToolbar from './format-toolbar'; import TinyMCE from './tinymce'; import { pickAriaProps } from './aria'; import { getPatterns } from './patterns'; import { withBlockEditContext } from '../block-edit/context'; -import TokenUI from './tokens/ui'; /** * Browser dependencies @@ -864,15 +862,6 @@ export class RichText extends Component { const classes = classnames( wrapperClassName, 'editor-rich-text' ); const record = this.getRecord(); - const formatToolbar = this.editor && ( - - ); - return (
    { isSelected && ! inlineToolbar && ( - { formatToolbar } + ) } { isSelected && inlineToolbar && (
    - { formatToolbar } +
    ) } - { isSelected && - - } - { placeholder } : placeholder } } - { isSelected && } + { isSelected && } ) } @@ -941,7 +923,7 @@ export class RichText extends Component { } RichText.defaultProps = { - formattingControls: FORMATTING_CONTROLS.map( ( { format } ) => format ), + formattingControls: [ 'bold', 'italic', 'link', 'strikethrough' ], format: 'string', value: '', }; diff --git a/packages/editor/src/components/rich-text/index.native.js b/packages/editor/src/components/rich-text/index.native.js index d6462e4b39a436..48f83b3dec3b15 100644 --- a/packages/editor/src/components/rich-text/index.native.js +++ b/packages/editor/src/components/rich-text/index.native.js @@ -20,11 +20,34 @@ import { split, toHTMLString, } from '@wordpress/rich-text'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import { FORMATTING_CONTROLS } from './formatting-controls'; + +const FORMATTING_CONTROLS = [ + { + icon: 'editor-bold', + title: __( 'Bold' ), + format: 'bold', + }, + { + icon: 'editor-italic', + title: __( 'Italic' ), + format: 'italic', + }, + { + icon: 'admin-links', + title: __( 'Link' ), + format: 'link', + }, + { + icon: 'editor-strikethrough', + title: __( 'Strikethrough' ), + format: 'strikethrough', + }, +]; const isRichTextValueEmpty = ( value ) => { return ! value || ! value.length; diff --git a/packages/editor/src/components/rich-text/tinymce.js b/packages/editor/src/components/rich-text/tinymce.js index 217f40eaa024bc..c1d752a1d3c7ad 100644 --- a/packages/editor/src/components/rich-text/tinymce.js +++ b/packages/editor/src/components/rich-text/tinymce.js @@ -168,6 +168,16 @@ export default class TinyMCE extends Component { setup: ( editor ) => { this.editor = editor; this.props.onSetup( editor ); + + editor.on( 'init', () => { + // See https://github.com/tinymce/tinymce/blob/master/src/core/main/ts/keyboard/FormatShortcuts.ts + [ 'b', 'i', 'u' ].forEach( ( character ) => { + editor.shortcuts.remove( `meta+${ character }` ); + } ); + [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ].forEach( ( number ) => { + editor.shortcuts.remove( `access+${ number }` ); + } ); + } ); }, } ); } diff --git a/packages/editor/src/components/rich-text/tokens/index.js b/packages/editor/src/components/rich-text/tokens/index.js deleted file mode 100644 index d74c50b425fc7f..00000000000000 --- a/packages/editor/src/components/rich-text/tokens/index.js +++ /dev/null @@ -1,96 +0,0 @@ -/** - * External dependencies - */ -import { has, isFunction } from 'lodash'; - -/** - * WordPress dependencies - */ -import { isValidIcon, normalizeIconObject } from '@wordpress/blocks'; -import { applyFilters } from '@wordpress/hooks'; - -/** - * Browser dependencies - */ -const { error } = window.console; - -/** - * Validates the token settings object. - * - * @param {string} name Token name. - * @param {Object} settings Token settings. - * @param {Object} state core/editor state. - * - * @return {Object} Validated token settings. - */ -export function validateTokenSettings( name, settings, state ) { - if ( typeof name !== 'string' ) { - error( - 'Token names must be strings.' - ); - return; - } - - if ( ! /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test( name ) ) { - error( - 'Token names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-token' - ); - return; - } - - if ( has( state, [ name ] ) ) { - error( - 'Token "' + name + '" is already registered.' - ); - return; - } - - settings = applyFilters( 'editor.registerToken', settings, name ); - - if ( ! settings || ! isFunction( settings.save ) ) { - error( - 'The "save" property must be specified and must be a valid function.' - ); - return; - } - - if ( has( settings, [ 'edit' ] ) && ! isFunction( settings.edit ) ) { - error( - 'The "edit" property must be a valid function.' - ); - return; - } - - if ( has( settings, [ 'keywords' ] ) && settings.keywords.length > 3 ) { - error( - 'The token "' + name + '" can have a maximum of 3 keywords.' - ); - return; - } - - if ( ! has( settings, [ 'title' ] ) || settings.title === '' ) { - error( - 'The token "' + name + '" must have a title.' - ); - return; - } - - if ( typeof settings.title !== 'string' ) { - error( - 'Token titles must be strings.' - ); - return; - } - - settings.icon = normalizeIconObject( settings.icon ); - - if ( ! isValidIcon( settings.icon.src ) ) { - error( - 'The icon passed is invalid. ' + - 'The icon should be a string, an element, a function, or an object following the specifications documented in https://wordpress.org/gutenberg/handbook/block-api/#icon-optional' - ); - return; - } - - return settings; -} diff --git a/packages/editor/src/components/rich-text/tokens/ui/index.js b/packages/editor/src/components/rich-text/tokens/ui/index.js deleted file mode 100644 index 9671551e2541b7..00000000000000 --- a/packages/editor/src/components/rich-text/tokens/ui/index.js +++ /dev/null @@ -1,94 +0,0 @@ -/** - * WordPress dependencies - */ -import { Component, Fragment, renderToString } from '@wordpress/element'; -import { getRectangleFromRange } from '@wordpress/dom'; -import { __ } from '@wordpress/i18n'; -import { withSelect } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import { InserterResultsPortal } from '../../../inserter'; - -class TokenUI extends Component { - constructor() { - super( ...arguments ); - - this.onHover = this.onHover.bind( this ); - this.onSelect = this.onSelect.bind( this ); - this.onSave = this.onSave.bind( this ); - - this.state = { - selected: null, - hovered: null, - }; - } - - getInsertPosition() { - const { containerRef, editor } = this.props; - - // The container is relatively positioned. - const containerPosition = containerRef.current.getBoundingClientRect(); - const rect = getRectangleFromRange( editor.selection.getRng() ); - - return { - top: rect.top - containerPosition.top, - left: rect.right - containerPosition.left, - height: rect.height, - }; - } - - onSave( { save } ) { - return ( attributes ) => { - const { editor } = this.props; - - if ( attributes ) { - editor.insertContent( renderToString( save( attributes ) ) ); - } - - this.setState( { selected: null } ); - }; - } - - onHover( settings ) { - this.setState( { hovered: !! settings } ); - } - - onSelect( settings ) { - this.setState( { selected: settings } ); - } - - render() { - const { hovered, selected } = this.state; - - return ( - - - { hovered && -
    - } - { selected && - - } - - ); - } -} - -export default withSelect( ( select ) => { - const { getTokenSettings } = select( 'core/editor' ); - - return { - items: Object.values( getTokenSettings() ), - }; -} )( TokenUI ); diff --git a/packages/editor/src/components/rich-text/tokens/ui/style.scss b/packages/editor/src/components/rich-text/tokens/ui/style.scss deleted file mode 100644 index cbc38140121aaf..00000000000000 --- a/packages/editor/src/components/rich-text/tokens/ui/style.scss +++ /dev/null @@ -1,7 +0,0 @@ -.blocks-inline-insertion-point { - display: block; - z-index: 1; - width: 4px; - margin-left: -2px; - background: theme(primary); -} diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 6a21f91a2f98e2..349cecd08dcfd8 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -752,21 +752,6 @@ export function updateEditorSettings( settings ) { }; } -export function registerToken( name, settings ) { - return { - type: 'REGISTER_TOKEN', - name, - settings, - }; -} - -export function unregisterToken( name ) { - return { - type: 'UNREGISTER_TOKEN', - name, - }; -} - /** * Returns an action object used in signalling that the user has enabled the publish sidebar. * diff --git a/packages/editor/src/store/index.js b/packages/editor/src/store/index.js index 542ab0a908b56d..fb8f9fe11d5cc6 100644 --- a/packages/editor/src/store/index.js +++ b/packages/editor/src/store/index.js @@ -1,8 +1,3 @@ -/** - * External Dependencies - */ -import { forOwn } from 'lodash'; - /** * WordPress Dependencies */ @@ -15,8 +10,6 @@ import reducer from './reducer'; import applyMiddlewares from './middlewares'; import * as selectors from './selectors'; import * as actions from './actions'; -import * as tokens from '../components/rich-text/core-tokens'; -import { validateTokenSettings } from '../components/rich-text/tokens'; /** * Module Constants @@ -31,12 +24,4 @@ const store = registerStore( MODULE_KEY, { } ); applyMiddlewares( store ); -forOwn( tokens, ( { name, settings } ) => { - settings = validateTokenSettings( name, settings, store.getState() ); - - if ( settings ) { - store.dispatch( actions.registerToken( name, settings ) ); - } -} ); - export default store; diff --git a/packages/editor/src/store/reducer.js b/packages/editor/src/store/reducer.js index df9f7bda241944..fc6168b73e9906 100644 --- a/packages/editor/src/store/reducer.js +++ b/packages/editor/src/store/reducer.js @@ -1115,28 +1115,6 @@ export function autosave( state = null, action ) { return state; } -/** - * Reducer managing the block types - * - * @param {Object} state Current state. - * @param {Object} action Dispatched action. - * - * @return {Object} Updated state. - */ -export function tokens( state = {}, action ) { - switch ( action.type ) { - case 'REGISTER_TOKEN': - return { - ...state, - [ action.name ]: action.settings, - }; - case 'UNREGISTER_TOKEN': - return omit( state, action.name ); - } - - return state; -} - export default optimist( combineReducers( { editor, currentPost, @@ -1153,6 +1131,5 @@ export default optimist( combineReducers( { template, autosave, settings, - tokens, postSavingLock, } ) ); diff --git a/packages/editor/src/style.scss b/packages/editor/src/style.scss index b1afcd28b8bee8..972322a31a8b6c 100644 --- a/packages/editor/src/style.scss +++ b/packages/editor/src/style.scss @@ -21,6 +21,7 @@ @import "./components/inner-blocks/style.scss"; @import "./components/inserter-with-shortcuts/style.scss"; @import "./components/inserter/style.scss"; +@import "./components/inserter-list-item/style.scss"; @import "./components/media-placeholder/style.scss"; @import "./components/page-attributes/style.scss"; @import "./components/panel-color-settings/style.scss"; @@ -38,10 +39,8 @@ @import "./components/post-visibility/style.scss"; @import "./components/post-title/style.scss"; @import "./components/post-trash/style.scss"; -@import "./components/rich-text/core-tokens/image/style.scss"; @import "./components/rich-text/format-toolbar/style.scss"; @import "./components/rich-text/style.scss"; -@import "./components/rich-text/tokens/ui/style.scss"; @import "./components/skip-to-selected-block/style.scss"; @import "./components/table-of-contents/style.scss"; @import "./components/template-validation-notice/style.scss"; diff --git a/packages/editor/src/utils/test/url.js b/packages/editor/src/utils/test/url.js deleted file mode 100644 index 66ce212bf564dc..00000000000000 --- a/packages/editor/src/utils/test/url.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Internal dependencies - */ -import { filterURLForDisplay } from '../url'; - -describe( 'filterURLForDisplay', () => { - it( 'should remove protocol', () => { - let url = filterURLForDisplay( 'http://wordpress.org' ); - expect( url ).toBe( 'wordpress.org' ); - url = filterURLForDisplay( 'https://wordpress.org' ); - expect( url ).toBe( 'wordpress.org' ); - } ); - it( 'should remove www subdomain', () => { - const url = filterURLForDisplay( 'http://www.wordpress.org' ); - expect( url ).toBe( 'wordpress.org' ); - } ); - it( 'should remove single trailing slash', () => { - const url = filterURLForDisplay( 'http://www.wordpress.org/' ); - expect( url ).toBe( 'wordpress.org' ); - } ); - it( 'should preserve slashes where the url has multiple in the path', () => { - const url = filterURLForDisplay( 'http://www.wordpress.org/something/' ); - expect( url ).toBe( 'wordpress.org/something/' ); - } ); - it( 'should preserve slash where the url has path after the initial slash', () => { - const url = filterURLForDisplay( 'http://www.wordpress.org/something' ); - expect( url ).toBe( 'wordpress.org/something' ); - } ); -} ); diff --git a/packages/editor/src/utils/url.js b/packages/editor/src/utils/url.js index 54d3ce6a4bbd0b..566775289f9777 100644 --- a/packages/editor/src/utils/url.js +++ b/packages/editor/src/utils/url.js @@ -16,22 +16,3 @@ import { addQueryArgs } from '@wordpress/url'; export function getWPAdminURL( page, query ) { return addQueryArgs( page, query ); } - -/** - * Returns a URL for display. - * - * @param {string} url Original URL. - * - * @return {string} Displayed URL. - */ -export function filterURLForDisplay( url ) { - // remove protocol and www prefixes - const filteredURL = url.replace( new RegExp( '^https?://(www\.)?' ), '' ); - - // ends with / and only has that single slash, strip it - if ( filteredURL.match( '^[^/]+/$' ) ) { - return filteredURL.replace( '/', '' ); - } - - return filteredURL; -} diff --git a/packages/format-library/.npmrc b/packages/format-library/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/format-library/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/format-library/README.md b/packages/format-library/README.md new file mode 100644 index 00000000000000..69d6f157279d07 --- /dev/null +++ b/packages/format-library/README.md @@ -0,0 +1,15 @@ +# Format library + +Format library for the WordPress editor. + +## Installation + +Install the module + +```bash +npm install @wordpress/format-library --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +

    Code is Poetry.

    diff --git a/packages/format-library/package.json b/packages/format-library/package.json new file mode 100644 index 00000000000000..9a68655a3e459d --- /dev/null +++ b/packages/format-library/package.json @@ -0,0 +1,36 @@ +{ + "name": "@wordpress/format-library", + "version": "1.0.0-alpha.0", + "description": "Format library for the WordPress editor.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "formats" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/format-library/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.0.0", + "@wordpress/components": "file:../components", + "@wordpress/dom": "file:../dom", + "@wordpress/editor": "file:../editor", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/keycodes": "file:../keycodes", + "@wordpress/rich-text": "file:../rich-text", + "@wordpress/url": "file:../url" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/format-library/src/bold/index.js b/packages/format-library/src/bold/index.js new file mode 100644 index 00000000000000..fa2b241ae5d7c9 --- /dev/null +++ b/packages/format-library/src/bold/index.js @@ -0,0 +1,36 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Fragment } from '@wordpress/element'; +import { toggleFormat } from '@wordpress/rich-text'; + +const name = 'core/bold'; + +export const bold = { + name, + title: __( 'Bold' ), + match: { + tagName: 'strong', + }, + edit( { isActive, value, onChange, ToolbarButton, Shortcut } ) { + const onToggle = () => onChange( toggleFormat( value, { type: name } ) ); + + return ( + + + + + ); + }, +}; diff --git a/packages/format-library/src/code/index.js b/packages/format-library/src/code/index.js new file mode 100644 index 00000000000000..9ac6d1fc3bfaa7 --- /dev/null +++ b/packages/format-library/src/code/index.js @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Fragment } from '@wordpress/element'; +import { toggleFormat } from '@wordpress/rich-text'; + +const name = 'core/code'; + +export const code = { + name, + title: __( 'Code' ), + match: { + tagName: 'code', + }, + edit( { value, onChange, Shortcut } ) { + const onToggle = () => onChange( toggleFormat( value, { type: name } ) ); + + return ( + + + + ); + }, +}; diff --git a/packages/format-library/src/image/index.js b/packages/format-library/src/image/index.js new file mode 100644 index 00000000000000..aad07328b5c2cf --- /dev/null +++ b/packages/format-library/src/image/index.js @@ -0,0 +1,80 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { Fragment, Component } from '@wordpress/element'; +import { insertObject } from '@wordpress/rich-text'; +import { MediaUpload } from '@wordpress/editor'; + +const ALLOWED_MEDIA_TYPES = [ 'image' ]; + +const name = 'core/image'; + +export const image = { + name, + title: __( 'Image' ), + keywords: [ __( 'photo' ), __( 'media' ) ], + object: true, + match: { + tagName: 'img', + }, + attributes: { + className: 'class', + style: 'style', + url: 'src', + alt: 'alt', + }, + edit: class ImageEdit extends Component { + constructor() { + super( ...arguments ); + this.openModal = this.openModal.bind( this ); + this.closeModal = this.closeModal.bind( this ); + this.state = { + modal: false, + }; + } + + openModal() { + this.setState( { modal: true } ); + } + + closeModal() { + this.setState( { modal: false } ); + } + + render() { + const { value, onChange, InserterListItem } = this.props; + + return ( + + } + title={ __( 'Inline Image' ) } + onClick={ this.openModal } + /> + { this.state.modal && { + this.closeModal(); + onChange( insertObject( value, { + type: name, + attributes: { + className: `wp-image-${ id }`, + style: `width: ${ Math.min( width, 150 ) }px;`, + url, + alt, + }, + } ) ); + } } + onClose={ this.closeModal } + render={ ( { open } ) => { + open(); + return null; + } } + /> } + + ); + } + }, +}; diff --git a/packages/format-library/src/index.js b/packages/format-library/src/index.js new file mode 100644 index 00000000000000..36012be4f1b5b1 --- /dev/null +++ b/packages/format-library/src/index.js @@ -0,0 +1,25 @@ +/** + * Internal dependencies + */ +import { bold } from './bold'; +import { code } from './code'; +import { image } from './image'; +import { italic } from './italic'; +import { link } from './link'; +import { strikethrough } from './strikethrough'; + +/** + * WordPress dependencies + */ +import { + registerFormatType, +} from '@wordpress/rich-text'; + +[ + bold, + code, + image, + italic, + link, + strikethrough, +].forEach( registerFormatType ); diff --git a/packages/format-library/src/italic/index.js b/packages/format-library/src/italic/index.js new file mode 100644 index 00000000000000..6a3728a70940e0 --- /dev/null +++ b/packages/format-library/src/italic/index.js @@ -0,0 +1,36 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Fragment } from '@wordpress/element'; +import { toggleFormat } from '@wordpress/rich-text'; + +const name = 'core/italic'; + +export const italic = { + name, + title: __( 'Italic' ), + match: { + tagName: 'em', + }, + edit( { isActive, value, onChange, ToolbarButton, Shortcut } ) { + const onToggle = () => onChange( toggleFormat( value, { type: name } ) ); + + return ( + + + + + ); + }, +}; diff --git a/packages/format-library/src/link/index.js b/packages/format-library/src/link/index.js new file mode 100644 index 00000000000000..a794ef26e55e30 --- /dev/null +++ b/packages/format-library/src/link/index.js @@ -0,0 +1,117 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component, Fragment } from '@wordpress/element'; +import { withSpokenMessages } from '@wordpress/components'; +import { + getTextContent, + applyFormat, + removeFormat, + slice, +} from '@wordpress/rich-text'; +import { isURL } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import InlineLinkUI from './inline'; + +const name = 'core/link'; + +export const link = { + name, + title: __( 'Link' ), + match: { + tagName: 'a', + }, + attributes: { + url: 'href', + target: 'target', + }, + edit: withSpokenMessages( class LinkEdit extends Component { + constructor() { + super( ...arguments ); + + this.addLink = this.addLink.bind( this ); + this.stopAddingLink = this.stopAddingLink.bind( this ); + this.onRemoveFormat = this.onRemoveFormat.bind( this ); + this.state = { + addingLink: false, + }; + } + + addLink() { + const { value, onChange } = this.props; + const text = getTextContent( slice( value ) ); + + if ( text && isURL( text ) ) { + onChange( applyFormat( value, { type: name, attributes: { url: text } } ) ); + } else { + this.setState( { addingLink: true } ); + } + } + + stopAddingLink() { + this.setState( { addingLink: false } ); + } + + onRemoveFormat() { + const { value, onChange, speak } = this.props; + + onChange( removeFormat( value, name ) ); + speak( __( 'Link removed.' ), 'assertive' ); + } + + render() { + const { isActive, activeAttributes, value, onChange, ToolbarButton, Shortcut } = this.props; + + return ( + + + + + + { isActive && } + { ! isActive && } + + + ); + } + } ), +}; diff --git a/packages/editor/src/components/rich-text/format-toolbar/link-container.js b/packages/format-library/src/link/inline.js similarity index 61% rename from packages/editor/src/components/rich-text/format-toolbar/link-container.js rename to packages/format-library/src/link/inline.js index fa7eabca07d73b..c72ef6158a2be3 100644 --- a/packages/editor/src/components/rich-text/format-toolbar/link-container.js +++ b/packages/format-library/src/link/inline.js @@ -5,39 +5,32 @@ import { __ } from '@wordpress/i18n'; import { Component, createRef } from '@wordpress/element'; import { ExternalLink, - Fill, IconButton, ToggleControl, withSpokenMessages, } from '@wordpress/components'; import { ESCAPE, LEFT, RIGHT, UP, DOWN, BACKSPACE, ENTER } from '@wordpress/keycodes'; -import { prependHTTP, safeDecodeURI } from '@wordpress/url'; +import { prependHTTP, safeDecodeURI, filterURLForDisplay } from '@wordpress/url'; import { create, insert, isCollapsed, applyFormat, } from '@wordpress/rich-text'; +import { URLInput, URLPopover } from '@wordpress/editor'; /** * Internal dependencies */ import PositionedAtSelection from './positioned-at-selection'; -import URLInput from '../../url-input'; -import { filterURLForDisplay } from '../../../utils/url'; -import URLPopover from '../../url-popover'; const stopKeyPropagation = ( event ) => event.stopPropagation(); -function getLinkAttributesFromFormat( { attributes: { href = '', target } = {} } = {} ) { - return { href, target }; -} - -function createLinkFormat( { href, opensInNewWindow } ) { +function createLinkFormat( { url, opensInNewWindow } ) { const format = { - type: 'a', + type: 'core/link', attributes: { - href, + url, }, }; @@ -53,7 +46,7 @@ function isShowingInput( props, state ) { return props.addingLink || state.editLink; } -const LinkEditor = ( { inputValue, onChangeInputValue, onKeyDown, submitLink, autocompleteRef } ) => ( +const LinkEditor = ( { value, onChangeInputValue, onKeyDown, submitLink, autocompleteRef } ) => ( // Disable reason: KeyPress must be suppressed so the block doesn't hide the toolbar /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
    @@ -72,7 +65,7 @@ const LinkEditor = ( { inputValue, onChangeInputValue, onKeyDown, submitLink, au /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ ); -const LinkViewer = ( { href, editLink } ) => ( +const LinkViewer = ( { url, editLink } ) => ( // Disable reason: KeyPress must be suppressed so the block doesn't hide the toolbar /* eslint-disable jsx-a11y/no-static-element-interactions */
    ( > - { filterURLForDisplay( safeDecodeURI( href ) ) } + { filterURLForDisplay( safeDecodeURI( url ) ) }
    /* eslint-enable jsx-a11y/no-static-element-interactions */ ); -class LinkContainer extends Component { +class InlineLinkUI extends Component { constructor() { super( ...arguments ); @@ -107,12 +100,12 @@ class LinkContainer extends Component { } static getDerivedStateFromProps( props, state ) { - const { href, target } = getLinkAttributesFromFormat( props.link ); + const { activeAttributes: { url, target } } = props; const opensInNewWindow = target === '_blank'; if ( ! isShowingInput( props, state ) ) { - if ( href !== state.inputValue ) { - return { inputValue: href }; + if ( url !== state.inputValue ) { + return { inputValue: url }; } if ( opensInNewWindow !== state.opensInNewWindow ) { @@ -140,12 +133,13 @@ class LinkContainer extends Component { } setLinkTarget( opensInNewWindow ) { + const { activeAttributes: { url }, value, onChange } = this.props; + this.setState( { opensInNewWindow } ); // Apply now if URL is not being edited. if ( ! isShowingInput( this.props, this.state ) ) { - const { href } = getLinkAttributesFromFormat( this.props.link ); - this.props.applyFormat( createLinkFormat( { href, opensInNewWindow } ) ); + onChange( applyFormat( value, createLinkFormat( { url, opensInNewWindow } ) ) ); } } @@ -155,29 +149,27 @@ class LinkContainer extends Component { } submitLink( event ) { - const { link, record } = this.props; + const { isActive, value, onChange, speak } = this.props; const { inputValue, opensInNewWindow } = this.state; - const href = prependHTTP( inputValue ); - const format = createLinkFormat( { href, opensInNewWindow } ); + const url = prependHTTP( inputValue ); + const format = createLinkFormat( { url, opensInNewWindow } ); - if ( isCollapsed( record ) && link === undefined ) { - const toInsert = applyFormat( create( { text: href } ), format, 0, href.length ); - this.props.onChange( insert( record, toInsert ) ); - } else { - this.props.applyFormat( format ); - } + event.preventDefault(); - if ( this.state.editLink ) { - this.props.speak( __( 'Link edited.' ), 'assertive' ); + if ( isCollapsed( value ) && ! isActive ) { + const toInsert = applyFormat( create( { text: url } ), format, 0, url.length ); + onChange( insert( value, toInsert ) ); + } else { + onChange( applyFormat( value, format ) ); } this.resetState(); - if ( ! link ) { - this.props.speak( __( 'Link added.' ), 'assertive' ); + if ( isActive ) { + speak( __( 'Link edited.' ), 'assertive' ); + } else { + speak( __( 'Link added.' ), 'assertive' ); } - - event.preventDefault(); } onClickOutside( event ) { @@ -199,51 +191,48 @@ class LinkContainer extends Component { } render() { - const { link, addingLink, record } = this.props; + const { isActive, activeAttributes: { url }, addingLink, value } = this.props; - if ( ! link && ! addingLink ) { + if ( ! isActive && ! addingLink ) { return null; } const { inputValue, opensInNewWindow } = this.state; - const { href } = getLinkAttributesFromFormat( link ); const showInput = isShowingInput( this.props, this.state ); return ( - - + ( + + ) } > - ( - - ) } - > - { showInput ? ( - - ) : ( - - ) } - - - + { showInput ? ( + + ) : ( + + ) } + + ); } } -export default withSpokenMessages( LinkContainer ); +export default withSpokenMessages( InlineLinkUI ); diff --git a/packages/editor/src/components/rich-text/format-toolbar/positioned-at-selection.js b/packages/format-library/src/link/positioned-at-selection.js similarity index 100% rename from packages/editor/src/components/rich-text/format-toolbar/positioned-at-selection.js rename to packages/format-library/src/link/positioned-at-selection.js diff --git a/packages/format-library/src/link/style.scss b/packages/format-library/src/link/style.scss new file mode 100644 index 00000000000000..e22da7b9422720 --- /dev/null +++ b/packages/format-library/src/link/style.scss @@ -0,0 +1,14 @@ +.editor-format-toolbar__link-container-content { + display: flex; +} + +.editor-format-toolbar__link-container-value { + margin: $grid-size - $border-width; + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 150px; + max-width: 500px; +} diff --git a/packages/format-library/src/strikethrough/index.js b/packages/format-library/src/strikethrough/index.js new file mode 100644 index 00000000000000..1b5c7c44416f0e --- /dev/null +++ b/packages/format-library/src/strikethrough/index.js @@ -0,0 +1,36 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Fragment } from '@wordpress/element'; +import { toggleFormat } from '@wordpress/rich-text'; + +const name = 'core/strikethrough'; + +export const strikethrough = { + name, + title: __( 'Strikethrough' ), + match: { + tagName: 'del', + }, + edit( { isActive, value, onChange, ToolbarButton, Shortcut } ) { + const onToggle = () => onChange( toggleFormat( value, { type: name } ) ); + + return ( + + + + + ); + }, +}; diff --git a/packages/format-library/src/style.scss b/packages/format-library/src/style.scss new file mode 100644 index 00000000000000..f421ddc28f9283 --- /dev/null +++ b/packages/format-library/src/style.scss @@ -0,0 +1 @@ +@import "./link/style.scss"; diff --git a/packages/rich-text/package.json b/packages/rich-text/package.json index 8280b49dd6076f..eadd056264532c 100644 --- a/packages/rich-text/package.json +++ b/packages/rich-text/package.json @@ -21,8 +21,10 @@ "react-native": "src/index", "dependencies": { "@babel/runtime": "^7.0.0", + "@wordpress/data": "file:../data", "@wordpress/escape-html": "file:../escape-html", - "lodash": "^4.17.10" + "lodash": "^4.17.10", + "rememo": "^3.0.0" }, "devDependencies": { "deep-freeze": "^0.0.1", diff --git a/packages/rich-text/src/apply-format.js b/packages/rich-text/src/apply-format.js index e9fd867a2138db..ebdf6ecaecfea3 100644 --- a/packages/rich-text/src/apply-format.js +++ b/packages/rich-text/src/apply-format.js @@ -2,13 +2,15 @@ * External dependencies */ -import { find } from 'lodash'; +import { find, reject } from 'lodash'; /** * Internal dependencies */ import { normaliseFormats } from './normalise-formats'; +import { insert } from './insert'; +import { ZERO_WIDTH_NO_BREAK_SPACE } from './special-characters'; /** * Apply a format object to a Rich Text value from the given `startIndex` to the @@ -30,21 +32,34 @@ export function applyFormat( ) { const newFormats = formats.slice( 0 ); - // If the selection is collapsed, expand start and end to the edges of the - // format. + // The selection is collpased. if ( startIndex === endIndex ) { const startFormat = find( newFormats[ startIndex ], { type: format.type } ); - while ( find( newFormats[ startIndex ], startFormat ) ) { - applyFormats( newFormats, startIndex, format ); - startIndex--; - } - - endIndex++; + // If the caret is at a format of the same type, expand start and end to + // the edges of the format. This is useful to apply new attributes. + if ( startFormat ) { + while ( find( newFormats[ startIndex ], startFormat ) ) { + applyFormats( newFormats, startIndex, format ); + startIndex--; + } - while ( find( newFormats[ endIndex ], startFormat ) ) { - applyFormats( newFormats, endIndex, format ); endIndex++; + + while ( find( newFormats[ endIndex ], startFormat ) ) { + applyFormats( newFormats, endIndex, format ); + endIndex++; + } + // Otherwise, insert a placeholder with the format so new input appears + // with the format applied. + } else { + const previousFormat = newFormats[ startIndex - 1 ] || []; + const hasType = find( previousFormat, { type: format.type } ); + + return insert( { formats, text, start, end }, { + formats: hasType ? [ reject( previousFormat, { type: format.type } ) ] : [ [ ...previousFormat, format ] ], + text: ZERO_WIDTH_NO_BREAK_SPACE, + } ); } } else { for ( let index = startIndex; index < endIndex; index++ ) { diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js index 8998ca7199f2a5..1c050735919fa9 100644 --- a/packages/rich-text/src/create.js +++ b/packages/rich-text/src/create.js @@ -1,3 +1,9 @@ +/** + * External dependencies + */ + +import { find } from 'lodash'; + /** * Internal dependencies */ @@ -5,6 +11,7 @@ import { isEmpty } from './is-empty'; import { isFormatEqual } from './is-format-equal'; import { createElement } from './create-element'; +import { getFormatTypes } from './get-format-types'; /** * Browser dependencies @@ -16,6 +23,47 @@ function createEmptyValue() { return { formats: [], text: '' }; } +function simpleFindKey( object, value ) { + for ( const key in object ) { + if ( object[ key ] === value ) { + return key; + } + } +} + +function toFormat( { type, attributes } ) { + const formatType = find( getFormatTypes(), ( { match } ) => + type === match.tagName + ); + + if ( ! formatType ) { + return attributes ? { type, attributes } : { type }; + } + + if ( ! attributes ) { + return { type: formatType.name }; + } + + const registeredAttributes = {}; + const unregisteredAttributes = {}; + + for ( const name in attributes ) { + const key = simpleFindKey( formatType.attributes, name ); + + if ( key ) { + registeredAttributes[ key ] = attributes[ name ]; + } else { + unregisteredAttributes[ name ] = attributes[ name ]; + } + } + + return { + type: formatType.name, + attributes: registeredAttributes, + unregisteredAttributes, + }; +} + /** * Create a RichText value from an `Element` tree (DOM), an HTML string or a * plain text string, with optionally a `Range` object to set the selection. If @@ -259,12 +307,13 @@ function createFromElement( { let format; if ( ! unwrapNode || ! unwrapNode( node ) ) { - const type = node.nodeName.toLowerCase(); - const attributes = getAttributes( { - element: node, - removeAttribute, + const newFormat = toFormat( { + type: node.nodeName.toLowerCase(), + attributes: getAttributes( { + element: node, + removeAttribute, + } ), } ); - const newFormat = attributes ? { type, attributes } : { type }; // Reuse the last format if it's equal. if ( isFormatEqual( newFormat, lastFormat ) ) { diff --git a/packages/rich-text/src/get-format-type.js b/packages/rich-text/src/get-format-type.js new file mode 100644 index 00000000000000..80c1c7e9e3ccb5 --- /dev/null +++ b/packages/rich-text/src/get-format-type.js @@ -0,0 +1,15 @@ +/** + * WordPress dependencies + */ +import { select } from '@wordpress/data'; + +/** + * Returns a registered format type. + * + * @param {string} name Format name. + * + * @return {?Object} Format type. + */ +export function getFormatType( name ) { + return select( 'core/rich-text' ).getFormatType( name ); +} diff --git a/packages/rich-text/src/get-format-types.js b/packages/rich-text/src/get-format-types.js new file mode 100644 index 00000000000000..95e6bc7db6fdec --- /dev/null +++ b/packages/rich-text/src/get-format-types.js @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import { select } from '@wordpress/data'; + +/** + * Returns all registered formats. + * + * @return {Array} Format settings. + */ +export function getFormatTypes() { + return select( 'core/rich-text' ).getFormatTypes(); +} diff --git a/packages/rich-text/src/index.js b/packages/rich-text/src/index.js index 61f149cc047677..a52b095499cea8 100644 --- a/packages/rich-text/src/index.js +++ b/packages/rich-text/src/index.js @@ -1,16 +1,24 @@ +import './store'; + export { applyFormat } from './apply-format'; export { concat } from './concat'; export { create } from './create'; export { getActiveFormat } from './get-active-format'; +export { getFormatType } from './get-format-type'; +export { getFormatTypes } from './get-format-types'; export { getTextContent } from './get-text-content'; export { isCollapsed } from './is-collapsed'; export { isEmpty, isEmptyLine } from './is-empty'; export { join } from './join'; +export { registerFormatType } from './register-format-type'; export { removeFormat } from './remove-format'; export { remove } from './remove'; export { replace } from './replace'; export { insert } from './insert'; +export { insertObject } from './insert-object'; export { slice } from './slice'; export { split } from './split'; export { apply, toDom as unstableToDom } from './to-dom'; export { toHTMLString } from './to-html-string'; +export { toggleFormat } from './toggle-format'; +export { unregisterFormatType } from './unregister-format-type'; diff --git a/packages/rich-text/src/insert-object.js b/packages/rich-text/src/insert-object.js new file mode 100644 index 00000000000000..b3486bcec4ebbe --- /dev/null +++ b/packages/rich-text/src/insert-object.js @@ -0,0 +1,36 @@ +/** + * Internal dependencies + */ + +import { insert } from './insert'; + +const OBJECT_REPLACEMENT_CHARACTER = '\ufffc'; + +/** + * Insert a format as an object into a Rich Text value at the given + * `startIndex`. Any content between `startIndex` and `endIndex` will be + * removed. Indices are retrieved from the selection if none are provided. + * + * @param {Object} value Value to modify. + * @param {string} formatToInsert Format to insert as object. + * @param {number} startIndex Start index. + * @param {number} endIndex End index. + * + * @return {Object} A new value with the object inserted. + */ +export function insertObject( + value, + formatToInsert, + startIndex, + endIndex +) { + const valueToInsert = { + text: OBJECT_REPLACEMENT_CHARACTER, + formats: [ [ { + ...formatToInsert, + object: true, + } ] ], + }; + + return insert( value, valueToInsert, startIndex, endIndex ); +} diff --git a/packages/rich-text/src/register-format-type.js b/packages/rich-text/src/register-format-type.js new file mode 100644 index 00000000000000..a800b0cb2503a8 --- /dev/null +++ b/packages/rich-text/src/register-format-type.js @@ -0,0 +1,84 @@ +/** + * External dependencies + */ +import { isFunction } from 'lodash'; + +/** + * WordPress dependencies + */ +import { select, dispatch } from '@wordpress/data'; +import { isValidIcon, normalizeIconObject } from '@wordpress/blocks'; + +/** + * Registers a new format provided a unique name and an object defining its + * behavior. + * + * @param {Object} settings Format settings. + * + * @return {?WPFormat} The format, if it has been successfully registered; + * otherwise `undefined`. + */ +export function registerFormatType( settings ) { + if ( typeof settings.name !== 'string' ) { + window.console.error( + 'Format names must be strings.' + ); + return; + } + + if ( ! /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test( settings.name ) ) { + window.console.error( + 'Format names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-format' + ); + return; + } + + if ( select( 'core/rich-text' ).getFormatType( settings.name ) ) { + window.console.error( + 'Format "' + settings.name + '" is already registered.' + ); + return; + } + + if ( ! settings || ! isFunction( settings.edit ) ) { + window.console.error( + 'The "edit" property must be specified and must be a valid function.' + ); + return; + } + + if ( ! ( 'title' in settings ) || settings.title === '' ) { + window.console.error( + 'The format "' + settings.name + '" must have a title.' + ); + return; + } + + if ( 'keywords' in settings && settings.keywords.length > 3 ) { + window.console.error( + 'The format "' + settings.name + '" can have a maximum of 3 keywords.' + ); + return; + } + + if ( typeof settings.title !== 'string' ) { + window.console.error( + 'Format titles must be strings.' + ); + return; + } + + settings.icon = normalizeIconObject( settings.icon ); + + if ( ! isValidIcon( settings.icon.src ) ) { + window.console.error( + 'The icon passed is invalid. ' + + 'The icon should be a string, an element, a function, or an object following the specifications documented in https://wordpress.org/gutenberg/handbook/format-api/#icon-optional' + ); + return; + } + + dispatch( 'core/rich-text' ).addFormatTypes( settings ); + + return settings; +} diff --git a/packages/rich-text/src/special-characters.js b/packages/rich-text/src/special-characters.js new file mode 100644 index 00000000000000..a607ab03fb7541 --- /dev/null +++ b/packages/rich-text/src/special-characters.js @@ -0,0 +1 @@ +export const ZERO_WIDTH_NO_BREAK_SPACE = '\uFEFF'; diff --git a/packages/rich-text/src/store/actions.js b/packages/rich-text/src/store/actions.js new file mode 100644 index 00000000000000..9b14ac26bca5fc --- /dev/null +++ b/packages/rich-text/src/store/actions.js @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import { castArray } from 'lodash'; + +/** + * Returns an action object used in signalling that format types have been + * added. + * + * @param {Array|Object} formatTypes Format types received. + * + * @return {Object} Action object. + */ +export function addFormatTypes( formatTypes ) { + return { + type: 'ADD_FORMAT_TYPES', + formatTypes: castArray( formatTypes ), + }; +} + +/** + * Returns an action object used to remove a registered format type. + * + * @param {string|Array} names Format name. + * + * @return {Object} Action object. + */ +export function removeFormatTypes( names ) { + return { + type: 'REMOVE_FORMAT_TYPES', + names: castArray( names ), + }; +} diff --git a/packages/rich-text/src/store/index.js b/packages/rich-text/src/store/index.js new file mode 100644 index 00000000000000..beaed276c2fd69 --- /dev/null +++ b/packages/rich-text/src/store/index.js @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as selectors from './selectors'; +import * as actions from './actions'; + +registerStore( 'core/rich-text', { reducer, selectors, actions } ); diff --git a/packages/rich-text/src/store/reducer.js b/packages/rich-text/src/store/reducer.js new file mode 100644 index 00000000000000..6002b1f55c34d5 --- /dev/null +++ b/packages/rich-text/src/store/reducer.js @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import { keyBy, omit } from 'lodash'; + +/** + * WordPress dependencies + */ +import { combineReducers } from '@wordpress/data'; + +/** + * Reducer managing the format types + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function formatTypes( state = {}, action ) { + switch ( action.type ) { + case 'ADD_FORMAT_TYPES': + return { + ...state, + ...keyBy( action.formatTypes, 'name' ), + }; + case 'REMOVE_FORMAT_TYPES': + return omit( state, action.names ); + } + + return state; +} + +export default combineReducers( { formatTypes } ); diff --git a/packages/rich-text/src/store/selectors.js b/packages/rich-text/src/store/selectors.js new file mode 100644 index 00000000000000..c5a28baf0abc22 --- /dev/null +++ b/packages/rich-text/src/store/selectors.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import createSelector from 'rememo'; + +/** + * Returns all the available format types. + * + * @param {Object} state Data state. + * + * @return {Array} Format types. + */ +export const getFormatTypes = createSelector( + ( state ) => Object.values( state.formatTypes ), + ( state ) => [ + state.formatTypes, + ] +); + +/** + * Returns a format type by name. + * + * @param {Object} state Data state. + * @param {string} name Format type name. + * + * @return {Object?} Format type. + */ +export function getFormatType( state, name ) { + return state.formatTypes[ name ]; +} diff --git a/packages/rich-text/src/store/test/reducer.js b/packages/rich-text/src/store/test/reducer.js new file mode 100644 index 00000000000000..4ae50a544d08eb --- /dev/null +++ b/packages/rich-text/src/store/test/reducer.js @@ -0,0 +1,47 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import { formatTypes } from '../reducer'; + +describe( 'formatTypes', () => { + it( 'should return an empty object as default state', () => { + expect( formatTypes( undefined, {} ) ).toEqual( {} ); + } ); + + it( 'should add add a new format type', () => { + const original = deepFreeze( { + 'core/bold': { name: 'core/bold' }, + } ); + + const state = formatTypes( original, { + type: 'ADD_FORMAT_TYPES', + formatTypes: [ { name: 'core/code' } ], + } ); + + expect( state ).toEqual( { + 'core/bold': { name: 'core/bold' }, + 'core/code': { name: 'core/code' }, + } ); + } ); + + it( 'should remove format types', () => { + const original = deepFreeze( { + 'core/bold': { name: 'core/bold' }, + 'core/code': { name: 'core/code' }, + } ); + + const state = formatTypes( original, { + type: 'REMOVE_FORMAT_TYPES', + names: [ 'core/code' ], + } ); + + expect( state ).toEqual( { + 'core/bold': { name: 'core/bold' }, + } ); + } ); +} ); diff --git a/packages/rich-text/src/test/apply-format.js b/packages/rich-text/src/test/apply-format.js index 55e99b66891bb8..73416cdb5cfacc 100644 --- a/packages/rich-text/src/test/apply-format.js +++ b/packages/rich-text/src/test/apply-format.js @@ -8,6 +8,7 @@ import deepFreeze from 'deep-freeze'; */ import { applyFormat } from '../apply-format'; +import { ZERO_WIDTH_NO_BREAK_SPACE } from '../special-characters'; import { getSparseArrayLength } from './helpers'; describe( 'applyFormat', () => { @@ -52,7 +53,7 @@ describe( 'applyFormat', () => { expect( getSparseArrayLength( result.formats ) ).toBe( 4 ); } ); - it( 'should not apply format on non existing format if selection is collapsed', () => { + it( 'should apply format in placeholder if selection is collapsed', () => { const record = { formats: [ , , , , [ a ], [ a ], [ a ], , , , , , , ], text: 'one two three', @@ -60,16 +61,16 @@ describe( 'applyFormat', () => { end: 0, }; const expected = { - formats: [ , , , , [ a ], [ a ], [ a ], , , , , , , ], - text: 'one two three', - start: 0, - end: 0, + formats: [ [ a2 ], , , , , [ a ], [ a ], [ a ], , , , , , , ], + text: `${ ZERO_WIDTH_NO_BREAK_SPACE }one two three`, + start: 1, + end: 1, }; const result = applyFormat( deepFreeze( record ), a2 ); expect( result ).toEqual( expected ); expect( result ).not.toBe( record ); - expect( getSparseArrayLength( result.formats ) ).toBe( 3 ); + expect( getSparseArrayLength( result.formats ) ).toBe( 4 ); } ); it( 'should apply format on existing format if selection is collapsed', () => { diff --git a/packages/rich-text/src/test/create.js b/packages/rich-text/src/test/create.js index 619c38826b67dc..2ce9b605817845 100644 --- a/packages/rich-text/src/test/create.js +++ b/packages/rich-text/src/test/create.js @@ -7,7 +7,6 @@ import { JSDOM } from 'jsdom'; /** * Internal dependencies */ - import { create } from '../create'; import { createElement } from '../create-element'; import { getSparseArrayLength, spec } from './helpers'; @@ -19,6 +18,11 @@ describe( 'create', () => { const em = { type: 'em' }; const strong = { type: 'strong' }; + beforeAll( () => { + // Initialize the rich-text store. + require( '../store' ); + } ); + spec.forEach( ( { description, multilineTag, settings, html, createRange, record } ) => { it( description, () => { const element = createElement( document, html ); diff --git a/packages/rich-text/src/test/to-dom.js b/packages/rich-text/src/test/to-dom.js index f68a3dda498980..7be38e49fa80d0 100644 --- a/packages/rich-text/src/test/to-dom.js +++ b/packages/rich-text/src/test/to-dom.js @@ -16,6 +16,11 @@ const { window } = new JSDOM(); const { document } = window; describe( 'recordToDom', () => { + beforeAll( () => { + // Initialize the rich-text store. + require( '../store' ); + } ); + spec.forEach( ( { description, multilineTag, record, startPath, endPath } ) => { it( description, () => { const { body, selection } = toDom( record, multilineTag ); diff --git a/packages/rich-text/src/test/to-html-string.js b/packages/rich-text/src/test/to-html-string.js index a65c3cb772447d..daadd733fd9a21 100644 --- a/packages/rich-text/src/test/to-html-string.js +++ b/packages/rich-text/src/test/to-html-string.js @@ -21,6 +21,11 @@ function createNode( HTML ) { } describe( 'toHTMLString', () => { + beforeAll( () => { + // Initialize the rich-text store. + require( '../store' ); + } ); + it( 'should extract recreate HTML 1', () => { const HTML = 'one two 🍒 three'; const element = createNode( `

    ${ HTML }

    ` ); diff --git a/packages/rich-text/src/to-tree.js b/packages/rich-text/src/to-tree.js index c1ced44a16c6b1..b40c04846b8b1c 100644 --- a/packages/rich-text/src/to-tree.js +++ b/packages/rich-text/src/to-tree.js @@ -3,6 +3,40 @@ */ import { split } from './split'; +import { getFormatType } from './get-format-type'; + +function fromFormat( { type, attributes, object } ) { + const formatType = getFormatType( type ); + + if ( ! formatType ) { + return { type, attributes, object }; + } + + if ( ! attributes ) { + return { + type: formatType.match.tagName, + object: formatType.object, + }; + } + + const elementAttributes = {}; + + for ( const name in attributes ) { + const key = formatType.attributes[ name ]; + + if ( key ) { + elementAttributes[ key ] = attributes[ name ]; + } else { + elementAttributes[ name ] = attributes[ name ]; + } + } + + return { + type: formatType.match.tagName, + object: formatType.object, + attributes: elementAttributes, + }; +} export function toTree( value, multilineTag, settings ) { if ( multilineTag ) { @@ -61,7 +95,7 @@ export function toTree( value, multilineTag, settings ) { const { type, attributes, object } = format; const parent = getParent( pointer ); - const newNode = append( parent, { type, attributes, object } ); + const newNode = append( parent, fromFormat( { type, attributes, object } ) ); if ( isText( pointer ) && getText( pointer ).length === 0 ) { remove( pointer ); diff --git a/packages/rich-text/src/toggle-format.js b/packages/rich-text/src/toggle-format.js new file mode 100644 index 00000000000000..147d9524df246b --- /dev/null +++ b/packages/rich-text/src/toggle-format.js @@ -0,0 +1,26 @@ +/** + * Internal dependencies + */ + +import { getActiveFormat } from './get-active-format'; +import { removeFormat } from './remove-format'; +import { applyFormat } from './apply-format'; + +/** + * Toggles a format object to a Rich Text value at the current selection. + * + * @param {Object} value Value to modify. + * @param {Object} format Format to apply or remove. + * + * @return {Object} A new value with the format applied or removed. + */ +export function toggleFormat( + value, + format +) { + if ( getActiveFormat( value, format.type ) ) { + return removeFormat( value, format.type ); + } + + return applyFormat( value, format ); +} diff --git a/packages/rich-text/src/unregister-format-type.js b/packages/rich-text/src/unregister-format-type.js new file mode 100644 index 00000000000000..a01b648622342e --- /dev/null +++ b/packages/rich-text/src/unregister-format-type.js @@ -0,0 +1,27 @@ +/** + * WordPress dependencies + */ +import { select, dispatch } from '@wordpress/data'; + +/** + * Unregisters a format. + * + * @param {string} name Format name. + * + * @return {?WPFormat} The previous format value, if it has been successfully + * unregistered; otherwise `undefined`. + */ +export function unregisterFormatType( name ) { + const oldFormat = select( 'core/rich-text' ).getFormatType( name ); + + if ( ! oldFormat ) { + window.console.error( + `Format ${ name } is not registered.` + ); + return; + } + + dispatch( 'core/rich-text' ).removeFormatTypes( name ); + + return oldFormat; +} diff --git a/packages/url/src/index.js b/packages/url/src/index.js index 98cd123cfc666d..cc368afdc19735 100644 --- a/packages/url/src/index.js +++ b/packages/url/src/index.js @@ -109,3 +109,22 @@ export function safeDecodeURI( uri ) { return uri; } } + +/** + * Returns a URL for display. + * + * @param {string} url Original URL. + * + * @return {string} Displayed URL. + */ +export function filterURLForDisplay( url ) { + // Remove protocol and www prefixes. + const filteredURL = url.replace( /^(?:https?:)\/\/(?:www\.)?/, '' ); + + // Ends with / and only has that single slash, strip it. + if ( filteredURL.match( /^[^\/]+\/$/ ) ) { + return filteredURL.replace( '/', '' ); + } + + return filteredURL; +} diff --git a/packages/url/src/test/index.test.js b/packages/url/src/test/index.test.js index 46548528f3bc10..20d6c3628e752a 100644 --- a/packages/url/src/test/index.test.js +++ b/packages/url/src/test/index.test.js @@ -14,6 +14,7 @@ import { removeQueryArgs, prependHTTP, safeDecodeURI, + filterURLForDisplay, } from '../'; describe( 'isURL', () => { @@ -202,3 +203,29 @@ describe( 'safeDecodeURI', () => { expect( safeDecodeURI( malformed ) ).toBe( malformed ); } ); } ); + +describe( 'filterURLForDisplay', () => { + it( 'should remove protocol', () => { + let url = filterURLForDisplay( 'http://wordpress.org' ); + expect( url ).toBe( 'wordpress.org' ); + url = filterURLForDisplay( 'https://wordpress.org' ); + expect( url ).toBe( 'wordpress.org' ); + } ); + it( 'should remove www subdomain', () => { + const url = filterURLForDisplay( 'http://www.wordpress.org' ); + expect( url ).toBe( 'wordpress.org' ); + } ); + it( 'should remove single trailing slash', () => { + const url = filterURLForDisplay( 'http://www.wordpress.org/' ); + expect( url ).toBe( 'wordpress.org' ); + } ); + it( 'should preserve slashes where the url has multiple in the path', () => { + const url = filterURLForDisplay( 'http://www.wordpress.org/something/' ); + expect( url ).toBe( 'wordpress.org/something/' ); + } ); + it( 'should preserve slash where the url has path after the initial slash', () => { + const url = filterURLForDisplay( 'http://www.wordpress.org/something' ); + expect( url ).toBe( 'wordpress.org/something' ); + } ); +} ); + diff --git a/webpack.config.js b/webpack.config.js index 30de6acc9d4963..d9c876cf525eac 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -54,6 +54,7 @@ const gutenbergPackages = [ 'editor', 'element', 'escape-html', + 'format-library', 'hooks', 'html-entities', 'i18n',