diff --git a/CHANGELOG.md b/CHANGELOG.md index dd607663..40e35884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,17 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [6.0.0](https://github.com/eea/volto-slate/compare/5.4.1...6.0.0) + +- Use block data form [`#240`](https://github.com/eea/volto-slate/pull/240) +- Add poweruser menu [`#234`](https://github.com/eea/volto-slate/pull/234) +- Release 6.0.0 [`de78cb5`](https://github.com/eea/volto-slate/commit/de78cb5495e0a8ea262ff45b05b87f900dccf5eb) + #### [5.4.1](https://github.com/eea/volto-slate/compare/5.4.0...5.4.1) -- chore(cypress): Fix paste html [`0bebd22`](https://github.com/eea/volto-slate/commit/0bebd222609ad8186d12be93c230ac1bc1b3c16d) +> 19 March 2022 + +- chore(cypress): Fix paste html [`#237`](https://github.com/eea/volto-slate/pull/237) #### [5.4.0](https://github.com/eea/volto-slate/compare/5.3.5...5.4.0) diff --git a/Jenkinsfile b/Jenkinsfile index f2bfaedb..f13f1313 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -184,10 +184,10 @@ pipeline { unstash "xunit-reports" unstash "cypress-coverage" def scannerHome = tool 'SonarQubeScanner'; - def nodeJS = tool 'NodeJS11'; + def nodeJS = tool 'NodeJS'; withSonarQubeEnv('Sonarqube') { sh '''sed -i "s#/opt/frontend/my-volto-project/src/addons/${GIT_NAME}/##g" xunit-reports/coverage/lcov.info''' - sh "export PATH=$PATH:${scannerHome}/bin:${nodeJS}/bin; sonar-scanner -Dsonar.javascript.lcov.reportPaths=./xunit-reports/coverage/lcov.info,./cypress-coverage/coverage/lcov.info -Dsonar.sources=./src -Dsonar.projectKey=$GIT_NAME-$BRANCH_NAME -Dsonar.projectVersion=$BRANCH_NAME-$BUILD_NUMBER" + sh "export PATH=${scannerHome}/bin:${nodeJS}/bin:$PATH; sonar-scanner -Dsonar.javascript.lcov.reportPaths=./xunit-reports/coverage/lcov.info,./cypress-coverage/coverage/lcov.info -Dsonar.sources=./src -Dsonar.projectKey=$GIT_NAME-$BRANCH_NAME -Dsonar.projectVersion=$BRANCH_NAME-$BUILD_NUMBER" sh '''try=2; while [ \$try -gt 0 ]; do curl -s -XPOST -u "${SONAR_AUTH_TOKEN}:" "${SONAR_HOST_URL}api/project_tags/set?project=${GIT_NAME}-${BRANCH_NAME}&tags=${SONARQUBE_TAGS},${BRANCH_NAME}" > set_tags_result; if [ \$(grep -ic error set_tags_result ) -eq 0 ]; then try=0; else cat set_tags_result; echo "... Will retry"; sleep 60; try=\$(( \$try - 1 )); fi; done''' } } diff --git a/package.json b/package.json index 003b9973..d361c684 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "volto-slate", - "version": "5.4.1", + "version": "6.0.0", "description": "Slate.js integration with Volto", "main": "src/index.js", "author": "European Environment Agency: IDM2 A-Team", diff --git a/src/blocks/Text/DefaultTextBlockEditor.jsx b/src/blocks/Text/DefaultTextBlockEditor.jsx index 7489eb69..5d5e7330 100644 --- a/src/blocks/Text/DefaultTextBlockEditor.jsx +++ b/src/blocks/Text/DefaultTextBlockEditor.jsx @@ -9,7 +9,7 @@ import { Dimmer, Loader, Message, Segment } from 'semantic-ui-react'; import { flattenToAppURL, getBaseUrl } from '@plone/volto/helpers'; import config from '@plone/volto/registry'; import { - InlineForm, + BlockDataForm, SidebarPortal, BlockChooserButton, } from '@plone/volto/components'; @@ -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}
: ''} @@ -264,7 +277,7 @@ export const DefaultTextBlockEditor = (props) => { <> - { @@ -274,6 +287,7 @@ export const DefaultTextBlockEditor = (props) => { }); }} formData={data} + block={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();