From b93deabdcb6d3118a600a9609ef5691ded1e4143 Mon Sep 17 00:00:00 2001 From: Rob Gietema Date: Mon, 4 Apr 2022 06:56:06 +0200 Subject: [PATCH] Add poweruser menu (#234) * Added poweruser menu. * Fix poweruser menu. * [JENKINS] - Fix stylelint * Add poweruser menu review (#238) * New features on table block (#235) * New features on table block * Add 2 new boolean options to table configuration: 'Show headers' and 'Make the table sortable' * Add text align option to table configuration * Clean up * Update tests * Add 'Vertical align' option * Update tests * [JENKINS] - Fix tests to work with Volto 15 * Bump version to 5.4.0 * set showHeaders as true by default * Replace showHeaders with hideHeaders * use align widget * Update tests * fix(table style): change display to inline-block for headers content * Add comment to public.less Co-authored-by: Alin Voinea * Automated release 5.4.0 * Add Sonarqube tag using clms-frontend addons list * chore(cypress): Fix paste html * Automated release 5.4.1 * Allow passing custom slate settings * WIP * WIP * Seems to be working * Properly select first option in menu * Add some instructions on slash menu * Try to cancel Esc * Add node about esc canceling * Add one more note * Return from component Co-authored-by: Alin Voinea Co-authored-by: Miu Razvan Co-authored-by: EEA Jenkins <@users.noreply.github.com> Co-authored-by: EEA Jenkins Co-authored-by: Alin Voinea Co-authored-by: Tiberiu Ichim Co-authored-by: Miu Razvan Co-authored-by: EEA Jenkins Author: Rob Gietema --- src/blocks/Text/DefaultTextBlockEditor.jsx | 13 ++ src/blocks/Text/ShortcutListing.jsx | 3 + src/blocks/Text/SlashMenu.jsx | 164 +++++++++++++++++++++ src/blocks/Text/index.js | 6 + src/blocks/Text/keyboard/cancelEsc.js | 7 + src/blocks/Text/keyboard/index.js | 2 + src/blocks/Text/keyboard/slashMenu.js | 16 ++ src/editor/SlateEditor.jsx | 55 ++++--- src/editor/less/editor.less | 25 ++++ 9 files changed, 274 insertions(+), 17 deletions(-) create mode 100644 src/blocks/Text/SlashMenu.jsx create mode 100644 src/blocks/Text/keyboard/cancelEsc.js create mode 100644 src/blocks/Text/keyboard/slashMenu.js diff --git a/src/blocks/Text/DefaultTextBlockEditor.jsx b/src/blocks/Text/DefaultTextBlockEditor.jsx index 7489eb69..1fa73970 100644 --- a/src/blocks/Text/DefaultTextBlockEditor.jsx +++ b/src/blocks/Text/DefaultTextBlockEditor.jsx @@ -23,6 +23,7 @@ import { } from 'volto-slate/utils'; import { Transforms } from 'slate'; +import PersistentSlashMenu from './SlashMenu'; import ShortcutListing from './ShortcutListing'; import MarkdownIntroduction from './MarkdownIntroduction'; import { handleKey } from './keyboard'; @@ -86,6 +87,17 @@ export const DefaultTextBlockEditor = (props) => { [props], ); + const slateSettings = React.useMemo( + () => ({ + ...config.settings.slate, + persistentHelpers: [ + ...config.settings.slate.persistentHelpers, + PersistentSlashMenu, + ], + }), + [], + ); + const onDrop = React.useCallback( (files) => { // TODO: need to fix setUploading, treat uploading indicator @@ -231,6 +243,7 @@ export const DefaultTextBlockEditor = (props) => { onKeyDown={handleKey} selected={selected} placeholder={placeholder} + slateSettings={slateSettings} /> {DEBUG ?
{block}
: ''} diff --git a/src/blocks/Text/ShortcutListing.jsx b/src/blocks/Text/ShortcutListing.jsx index 665dfdff..41c09dce 100644 --- a/src/blocks/Text/ShortcutListing.jsx +++ b/src/blocks/Text/ShortcutListing.jsx @@ -12,6 +12,9 @@ const ShortcutListing = (props) => { + + Type a slash (/) to change block type + {Object.entries(hotkeys || {}).map(([shortcut, { format, type }]) => ( {`${shortcut}: ${format}`} ))} diff --git a/src/blocks/Text/SlashMenu.jsx b/src/blocks/Text/SlashMenu.jsx new file mode 100644 index 00000000..c9659165 --- /dev/null +++ b/src/blocks/Text/SlashMenu.jsx @@ -0,0 +1,164 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { filter, isEmpty } from 'lodash'; +import { Menu } from 'semantic-ui-react'; +import { useIntl, FormattedMessage } from 'react-intl'; +import { Icon } from '@plone/volto/components'; + +const emptySlateBlock = () => ({ + value: [ + { + children: [ + { + text: '', + }, + ], + type: 'p', + }, + ], + plaintext: '', +}); + +const useIsMounted = () => { + const ref = React.useRef(); + React.useEffect(() => { + ref.current = true; + return () => (ref.current = false); + }, []); + return ref.current; +}; + +const SlashMenu = ({ + currentBlock, + onMutateBlock, + selected, + availableBlocks, +}) => { + const intl = useIntl(); + + return ( +
+ + {availableBlocks.map((block, index) => ( + { + // onInsertBlock(currentBlock, { '@type': block.id }); + onMutateBlock(currentBlock, { '@type': block.id }); + e.stopPropagation(); + }} + > + + {intl.formatMessage({ + id: block.title, + defaultMessage: block.title, + })} + + ))} + {availableBlocks.length === 0 && ( + + + + )} + +
+ ); +}; + +SlashMenu.propTypes = { + currentBlock: PropTypes.string.isRequired, + onInsertBlock: PropTypes.func, + selected: PropTypes.number, + blocksConfig: PropTypes.arrayOf(PropTypes.any), +}; + +/** + * A SlashMenu wrapper implemented as a volto-slate PersistentHelper. + */ +const PersistentSlashMenu = ({ editor }) => { + const props = editor.getBlockProps(); + const { + block, + blocksConfig, + data, + onMutateBlock, + properties, + selected, + allowedBlocks, + detached, + } = props; + const disableNewBlocks = data?.disableNewBlocks || detached; + + const [slashMenuSelected, setSlashMenuSelected] = React.useState(0); + + const useAllowedBlocks = !isEmpty(allowedBlocks); + const slashCommand = data.plaintext?.trim().match(/^\/([a-z]*)$/); + + const availableBlocks = React.useMemo( + () => + filter(blocksConfig, (item) => + useAllowedBlocks + ? allowedBlocks.includes(item.id) + : typeof item.restricted === 'function' + ? !item.restricted({ properties, block: item }) + : !item.restricted, + ) + .filter( + // TODO: make it work with intl? + (block) => slashCommand && block.id.indexOf(slashCommand[1]) === 0, + ) + .sort((a, b) => (a.title < b.title ? -1 : 1)), + [allowedBlocks, blocksConfig, properties, slashCommand, useAllowedBlocks], + ); + + const slashMenuSize = availableBlocks.length; + const show = selected && slashCommand && !disableNewBlocks; + + const isMounted = useIsMounted(); + + React.useEffect(() => { + if (isMounted && show && slashMenuSelected > slashMenuSize - 1) { + setSlashMenuSelected(slashMenuSize - 1); + } + }, [show, slashMenuSelected, isMounted, slashMenuSize]); + + editor.showSlashMenu = show; + + editor.slashEnter = () => + slashMenuSize > 0 && + onMutateBlock( + block, + { + '@type': availableBlocks[slashMenuSelected].id, + }, + emptySlateBlock(), + ); + + editor.slashArrowUp = () => + setSlashMenuSelected( + slashMenuSelected === 0 ? slashMenuSize - 1 : slashMenuSelected - 1, + ); + + editor.slashArrowDown = () => + setSlashMenuSelected( + slashMenuSelected >= slashMenuSize - 1 ? 0 : slashMenuSelected + 1, + ); + + return show ? ( + + ) : ( + '' + ); +}; + +export default PersistentSlashMenu; diff --git a/src/blocks/Text/index.js b/src/blocks/Text/index.js index 3a5f8248..014adda1 100644 --- a/src/blocks/Text/index.js +++ b/src/blocks/Text/index.js @@ -16,6 +16,8 @@ import { moveListItemUp, traverseBlocks, unwrapEmptyString, + slashMenu, + cancelEsc, } from './keyboard'; import { withDeleteSelectionOnEnter } from 'volto-slate/editor/extensions'; import { @@ -58,14 +60,17 @@ export default (config) => { joinWithNextBlock, // Delete at end of block joins with next block ], Enter: [ + slashMenu, unwrapEmptyString, softBreak, // Handles shift+Enter as a newline (
) ], ArrowUp: [ + slashMenu, moveListItemUp, // Move up a list with with Ctrl+up goUp, // Select previous block ], ArrowDown: [ + slashMenu, moveListItemDown, // Move down a list item with Ctrl+down goDown, // Select next block ], @@ -73,6 +78,7 @@ export default (config) => { indentListItems, // and behaviour for list items traverseBlocks, ], + Escape: [cancelEsc], }, textblockDetachedKeyboardHandlers: { Enter: [ diff --git a/src/blocks/Text/keyboard/cancelEsc.js b/src/blocks/Text/keyboard/cancelEsc.js new file mode 100644 index 00000000..a60ab827 --- /dev/null +++ b/src/blocks/Text/keyboard/cancelEsc.js @@ -0,0 +1,7 @@ +export const cancelEsc = ({ editor, event }) => { + // TODO: this doesn't work, escape canceling doesn't work. + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + event.preventDefault(); + return true; +}; diff --git a/src/blocks/Text/keyboard/index.js b/src/blocks/Text/keyboard/index.js index 247980b2..78538983 100644 --- a/src/blocks/Text/keyboard/index.js +++ b/src/blocks/Text/keyboard/index.js @@ -8,6 +8,8 @@ export * from './moveListItems'; export * from './softBreak'; export * from './traverseBlocks'; export * from './unwrapEmptyString'; +export * from './slashMenu'; +export * from './cancelEsc'; /** * Takes all the handlers from `slate.textblockKeyboardHandlers` that are diff --git a/src/blocks/Text/keyboard/slashMenu.js b/src/blocks/Text/keyboard/slashMenu.js new file mode 100644 index 00000000..b3074b64 --- /dev/null +++ b/src/blocks/Text/keyboard/slashMenu.js @@ -0,0 +1,16 @@ +export const slashMenu = ({ editor, event }) => { + if (!editor.showSlashMenu) return; + + const { slashArrowUp, slashArrowDown, slashEnter } = editor; + + const handlers = { + ArrowUp: slashArrowUp, + ArrowDown: slashArrowDown, + Enter: slashEnter, + }; + + const handler = handlers[event.key]; + if (handler) handler(); + + return true; +}; diff --git a/src/editor/SlateEditor.jsx b/src/editor/SlateEditor.jsx index c1fc9257..bd241a48 100644 --- a/src/editor/SlateEditor.jsx +++ b/src/editor/SlateEditor.jsx @@ -64,12 +64,12 @@ class SlateEditor extends Component { const uid = uuid(); // used to namespace the editor's plugins - const { slate } = config.settings; + this.slateSettings = props.slateSettings || config.settings.slate; this.state = { editor: this.createEditor(uid), - showExpandedToolbar: config.settings.slate.showExpandedToolbar, - internalValue: this.props.value || slate.defaultValue(), + showExpandedToolbar: this.slateSettings.showExpandedToolbar, + internalValue: this.props.value || this.slateSettings.defaultValue(), uid, }; @@ -85,6 +85,10 @@ class SlateEditor extends Component { } createEditor(uid) { + // extensions are "editor plugins" or "editor wrappers". It's a similar + // similar to OOP inheritance, where a callable creates a new copy of the + // editor, while replacing or adding new capabilities to that editor. + // Extensions are purely JS, no React components. const editor = makeEditor({ extensions: this.props.extensions }); // When the editor loses focus it no longer has a valid selections. This @@ -110,7 +114,7 @@ class SlateEditor extends Component { multiDecorator([node, path]) { // Decorations (such as higlighting node types, selection, etc). - const { runtimeDecorators = [] } = config.settings.slate; + const { runtimeDecorators = [] } = this.slateSettings; return runtimeDecorators.reduce( (acc, deco) => deco(this.state.editor, [node, path], acc), [], @@ -210,7 +214,7 @@ class SlateEditor extends Component { className, renderExtensions = [], } = this.props; - const { slate } = config.settings; + const slateSettings = this.slateSettings; // renderExtensions is needed because the editor is memoized, so if these // extensions need an updated state (for example to insert updated @@ -219,6 +223,19 @@ class SlateEditor extends Component { (acc, apply) => apply(acc), this.state.editor, ); + + // Reset selection if field is reset + if ( + editor.selection && + this.props.value.length === 1 && + this.props.value[0].children.length === 1 && + this.props.value[0].children[0].text === '' + ) { + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }); + } this.editor = editor; if (testingEditorRef) { @@ -239,21 +256,25 @@ class SlateEditor extends Component { {selected ? ( <> - {Object.keys(slate.elementToolbarButtons).map((t) => { - return ( - - {slate.elementToolbarButtons[t].map((Btn) => { - return ; - })} - - ); - })} + {Object.keys(slateSettings.elementToolbarButtons).map( + (t, i) => { + return ( + + {slateSettings.elementToolbarButtons[t].map( + (Btn, b) => { + return ; + }, + )} + + ); + }, + )} ) : ( '' @@ -299,13 +320,13 @@ class SlateEditor extends Component { }, 200); }} onKeyDown={(event) => { - const handled = handleHotKeys(editor, event, slate); + const handled = handleHotKeys(editor, event, slateSettings); if (handled) return; onKeyDown && onKeyDown({ editor, event }); }} /> {selected && - slate.persistentHelpers.map((Helper, i) => { + slateSettings.persistentHelpers.map((Helper, i) => { return ; })} {this.props.debug ? ( diff --git a/src/editor/less/editor.less b/src/editor/less/editor.less index 4b5ab5e7..0c4362ad 100644 --- a/src/editor/less/editor.less +++ b/src/editor/less/editor.less @@ -145,4 +145,29 @@ } } +.power-user-menu { + position: absolute; + z-index: 10; + top: 29px; + left: -9px; + width: 210px; + background-color: rgba(255, 255, 255, 0.975); + border-radius: 2px; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.05); + + .ui.menu { + border: 0; + border-radius: 2px; + + .icon { + margin-right: 12px; + vertical-align: middle; + } + + .item.active { + background: #efefef !important; + } + } +} + .loadAddonOverrides();