From 5069cd681a45ff785f1d13291b0590ccd6bfae1c Mon Sep 17 00:00:00 2001 From: Suzanne Aitchison Date: Tue, 19 Oct 2021 09:22:54 +0100 Subject: [PATCH] Markdown editor toolbar (#14876) * rough starting point, roving tabindex in toolbar, bold and italic buttons * refactor, add link * add ul * add ordered list * core formatters in place, wip * overflow options * add keyboard shortcuts * tidy up some dodgy classes * add mocks for runtime in test and storybook, use correct modifier key for tooltip * style tweaks * refactor tooltips * add markdown formatters tests * add tests for toolbar component, fix mistake in overflow menu tooltips * undo change no longer needed to button * fix issue accessing runtime in formatters file * only show darkened buttons and tooltips when focus-visible is true * mobile view * fix for responsive buttons & roving tabindex * tweaks from PR review * update cursor position on link insertion * add a new line after block selection formatting * align icons in center * tidy up overflow menu listeners * small refactors * test for new text area util * tidy up new lines after syntaxes * fix logic in cursor offsets for links * prevent scroll jumps after inserting syntax * some style tweaks * insert level 2 heading with new lines above and below * update icons * use margin instead of gap --- .../stylesheets/components/buttons.scss | 13 +- .../components/editor-toolbar.scss | 4 + .../stylesheets/components/tooltips.scss | 49 ++- app/assets/stylesheets/crayons.scss | 1 + app/helpers/authentication_helper.rb | 2 +- app/javascript/.storybook/preview-head.html | 7 + app/javascript/.storybook/preview.js | 1 + .../admin/controllers/config_controller.js | 2 +- app/javascript/crayons/Button/Button.jsx | 30 ++ .../crayons/Button/__tests__/Button.test.jsx | 7 + .../__snapshots__/Button.test.jsx.snap | 2 + .../MarkdownToolbar/MarkdownToolbar.jsx | 314 ++++++++++++++++++ .../__stories__/MarkdownToolbar.stories.jsx | 30 ++ .../__tests__/MarkdownToolbar.test.jsx | 55 +++ .../markdownSyntaxFormatters.test.js | 258 ++++++++++++++ .../crayons/MarkdownToolbar/icons.jsx | 185 +++++++++++ .../crayons/MarkdownToolbar/index.js | 1 + .../markdownSyntaxFormatters.js | 201 +++++++++++ app/javascript/crayons/index.js | 1 + .../utilities/__tests__/textAreaUtils.test.js | 41 ++- app/javascript/utilities/textAreaUtils.js | 25 +- .../settings/forms/_authentication.html.erb | 2 +- app/views/comments/_comment_header.html.erb | 4 +- .../_email_registration_form.html.erb | 10 +- spec/helpers/authentication_helper_spec.rb | 4 +- 25 files changed, 1197 insertions(+), 52 deletions(-) create mode 100644 app/assets/stylesheets/components/editor-toolbar.scss create mode 100644 app/javascript/.storybook/preview-head.html create mode 100644 app/javascript/crayons/MarkdownToolbar/MarkdownToolbar.jsx create mode 100644 app/javascript/crayons/MarkdownToolbar/__stories__/MarkdownToolbar.stories.jsx create mode 100644 app/javascript/crayons/MarkdownToolbar/__tests__/MarkdownToolbar.test.jsx create mode 100644 app/javascript/crayons/MarkdownToolbar/__tests__/markdownSyntaxFormatters.test.js create mode 100644 app/javascript/crayons/MarkdownToolbar/icons.jsx create mode 100644 app/javascript/crayons/MarkdownToolbar/index.js create mode 100644 app/javascript/crayons/MarkdownToolbar/markdownSyntaxFormatters.js diff --git a/app/assets/stylesheets/components/buttons.scss b/app/assets/stylesheets/components/buttons.scss index 549c962b7..45c720ff7 100644 --- a/app/assets/stylesheets/components/buttons.scss +++ b/app/assets/stylesheets/components/buttons.scss @@ -75,9 +75,7 @@ &[href]:hover, &[href]:focus, &:hover:enabled, - &:active:enabled, - &:focus:enabled, - &:focus-within { + &:active:enabled { background-color: var(--bg-hover); border-color: var(--border-hover); box-shadow: var(--shadow-hover); @@ -133,6 +131,15 @@ } } +.js-focus-visible .crayons-btn.focus-visible:focus, +.js-focus-visible .crayons-btn.focus-visible:focus-within { + background-color: var(--bg-hover); + border-color: var(--border-hover); + box-shadow: var(--shadow-hover); + color: var(--color-hover); + z-index: var(--z-elevate); +} + .crayons-btn--secondary { --bg: var(--button-secondary-bg); --bg-hover: var(--button-secondary-bg-hover); diff --git a/app/assets/stylesheets/components/editor-toolbar.scss b/app/assets/stylesheets/components/editor-toolbar.scss new file mode 100644 index 000000000..480375d58 --- /dev/null +++ b/app/assets/stylesheets/components/editor-toolbar.scss @@ -0,0 +1,4 @@ +.editor-toolbar { + display: flex; + background: var(--base-0); +} diff --git a/app/assets/stylesheets/components/tooltips.scss b/app/assets/stylesheets/components/tooltips.scss index 827ad670b..a11fd8b4c 100644 --- a/app/assets/stylesheets/components/tooltips.scss +++ b/app/assets/stylesheets/components/tooltips.scss @@ -1,34 +1,47 @@ -.crayons-tooltip[data-tooltip] { - position: relative; - - &:after { - all: unset; - content: attr(data-tooltip); +.crayons-tooltip { + &__content { position: absolute; - bottom: 100%; - left: 0; + left: 50%; + top: 100%; + transform: translate(-50%, calc(-1 * var(--su-1))); background: var(--base-a90); color: var(--base-inverted); font-size: var(--fs-s); - padding: var(--su-2) var(--su-3); - max-width: 256px; - width: max-content; - border-radius: var(--radius); font-family: var(--ff-sans-serif); font-weight: var(--fw-normal); line-height: var(--lh-tight); - transform: translateY(var(--su-1)); - opacity: var(--opacity-0); + padding: var(--su-2) var(--su-3); transition: all var(--transition-props); - text-align: left; transition-delay: 250ms; z-index: var(--z-popover); - pointer-events: none; + border-radius: var(--radius); + width: max-content; + opacity: var(--opacity-0); + } + + .js-focus-visible &__activator.focus-visible:focus, + &__activator:hover { + .crayons-tooltip__content:not(.crayons-tooltip__suppressed) { + opacity: 1; + transition-delay: 0; + transform: translate(-50%, var(--su-1)); + } + } +} + +// Minimal version for hover tooltips not triggered by keyboard +.crayons-hover-tooltip[data-tooltip] { + position: relative; + + &:after { + @extend .crayons-tooltip__content; + content: attr(data-tooltip); + left: 0; + bottom: 100%; + transform: translateY(var(--su-1)); } &:hover { - z-index: var(--z-popover); - &:after { opacity: 1; transform: translateY(calc(-1 * var(--su-1))); diff --git a/app/assets/stylesheets/crayons.scss b/app/assets/stylesheets/crayons.scss index c9ab28f4d..395790e09 100644 --- a/app/assets/stylesheets/crayons.scss +++ b/app/assets/stylesheets/crayons.scss @@ -15,6 +15,7 @@ @import 'components/cards'; @import 'components/comments'; @import 'components/dropdowns'; +@import 'components/editor-toolbar'; @import 'components/forms'; @import 'components/hamburger'; @import 'components/header'; diff --git a/app/helpers/authentication_helper.rb b/app/helpers/authentication_helper.rb index 6e6a66e9e..fa462933c 100644 --- a/app/helpers/authentication_helper.rb +++ b/app/helpers/authentication_helper.rb @@ -52,7 +52,7 @@ module AuthenticationHelper end def tooltip_class_on_auth_provider_enablebtn - invite_only_mode_or_no_enabled_auth_options ? "crayons-tooltip" : "" + invite_only_mode_or_no_enabled_auth_options ? "crayons-hover-tooltip" : "" end def disabled_attr_on_auth_provider_enable_btn diff --git a/app/javascript/.storybook/preview-head.html b/app/javascript/.storybook/preview-head.html new file mode 100644 index 000000000..a30435dfe --- /dev/null +++ b/app/javascript/.storybook/preview-head.html @@ -0,0 +1,7 @@ + diff --git a/app/javascript/.storybook/preview.js b/app/javascript/.storybook/preview.js index cf38a6ca9..ce1b36614 100644 --- a/app/javascript/.storybook/preview.js +++ b/app/javascript/.storybook/preview.js @@ -2,6 +2,7 @@ import { h } from 'preact'; import { addDecorator, addParameters } from '@storybook/preact'; import { DocsPage, DocsContainer } from '@storybook/addon-docs/blocks'; import { jsxDecorator } from 'storybook-addon-jsx'; +import 'focus-visible'; import '../../assets/stylesheets/minimal.scss'; import '../../assets/stylesheets/views.scss'; diff --git a/app/javascript/admin/controllers/config_controller.js b/app/javascript/admin/controllers/config_controller.js index 8b0f23d4d..dd2a97b91 100644 --- a/app/javascript/admin/controllers/config_controller.js +++ b/app/javascript/admin/controllers/config_controller.js @@ -270,7 +270,7 @@ export default class ConfigController extends Controller { const targetAuthDisableBtn = document.querySelector( '[data-enable-auth="true"]', ); - targetAuthDisableBtn.parentElement.classList.add('crayons-tooltip'); + targetAuthDisableBtn.parentElement.classList.add('crayons-hover-tooltip'); targetAuthDisableBtn.parentElement.setAttribute( 'data-tooltip', 'To edit this, you must first enable Email address as a registration option', diff --git a/app/javascript/crayons/Button/Button.jsx b/app/javascript/crayons/Button/Button.jsx index 8121b3ad1..1e096c11f 100644 --- a/app/javascript/crayons/Button/Button.jsx +++ b/app/javascript/crayons/Button/Button.jsx @@ -1,4 +1,5 @@ import { h } from 'preact'; +import { useState } from 'preact/hooks'; import PropTypes from 'prop-types'; import { defaultChildrenPropTypes } from '../../common-prop-types'; @@ -9,6 +10,7 @@ function getAdditionalClassNames({ size, inverted, disabled, + tooltip, }) { let additionalClassNames = ''; @@ -36,6 +38,10 @@ function getAdditionalClassNames({ additionalClassNames += ` ${className}`; } + if (tooltip) { + additionalClassNames += ` crayons-tooltip__activator`; + } + return additionalClassNames; } @@ -57,11 +63,23 @@ export const Button = (props) => { onMouseOut, onFocus, onBlur, + onKeyUp, tabIndex, title, + tooltip, ...restOfProps } = props; + const [suppressTooltip, setSuppressTooltip] = useState(false); + + const handleKeyUp = (event) => { + onKeyUp?.(event); + if (!tooltip) { + return; + } + setSuppressTooltip(event.key === 'Escape'); + }; + const ComponentName = tagName; const Icon = icon; const otherProps = @@ -80,12 +98,14 @@ export const Button = (props) => { inverted, disabled: tagName === 'a' && disabled, children, + tooltip, })}`} onClick={onClick} onMouseOver={onMouseOver} onMouseOut={onMouseOut} onFocus={onFocus} onBlur={onBlur} + onKeyUp={handleKeyUp} tabIndex={tabIndex} title={title} {...otherProps} @@ -101,6 +121,15 @@ export const Button = (props) => { {contentType !== 'text' && contentType === 'icon-right' && Icon && ( )} + {tooltip ? ( + + {tooltip} + + ) : null} ); }; @@ -162,4 +191,5 @@ Button.propTypes = { onBlur: PropTypes.func, tabIndex: PropTypes.number, title: PropTypes.string, + tooltip: PropTypes.node, }; diff --git a/app/javascript/crayons/Button/__tests__/Button.test.jsx b/app/javascript/crayons/Button/__tests__/Button.test.jsx index db06801ed..7a019e70b 100644 --- a/app/javascript/crayons/Button/__tests__/Button.test.jsx +++ b/app/javascript/crayons/Button/__tests__/Button.test.jsx @@ -181,4 +181,11 @@ describe(', + ); + expect(container.innerHTML).toMatchSnapshot(); + }); }); diff --git a/app/javascript/crayons/Button/__tests__/__snapshots__/Button.test.jsx.snap b/app/javascript/crayons/Button/__tests__/__snapshots__/Button.test.jsx.snap index b01b225ea..80a73ab21 100644 --- a/app/javascript/crayons/Button/__tests__/__snapshots__/Button.test.jsx.snap +++ b/app/javascript/crayons/Button/__tests__/__snapshots__/Button.test.jsx.snap @@ -23,3 +23,5 @@ exports[`"`; exports[`"`; + +exports[`"`; diff --git a/app/javascript/crayons/MarkdownToolbar/MarkdownToolbar.jsx b/app/javascript/crayons/MarkdownToolbar/MarkdownToolbar.jsx new file mode 100644 index 000000000..23623ac01 --- /dev/null +++ b/app/javascript/crayons/MarkdownToolbar/MarkdownToolbar.jsx @@ -0,0 +1,314 @@ +/* global Runtime */ +import { h } from 'preact'; +import { useState, useLayoutEffect } from 'preact/hooks'; +import { + coreSyntaxFormatters, + secondarySyntaxFormatters, +} from './markdownSyntaxFormatters'; +import { Overflow, Help } from './icons'; +import { Button } from '@crayons'; +import { KeyboardShortcuts } from '@components/useKeyboardShortcuts'; +import { BREAKPOINTS, useMediaQuery } from '@components/useMediaQuery'; +import { getIndexOfLineStart } from '@utilities/textAreaUtils'; + +export const MarkdownToolbar = ({ textAreaId }) => { + const [textArea, setTextArea] = useState(null); + const [overflowMenuOpen, setOverflowMenuOpen] = useState(false); + const smallScreen = useMediaQuery(`(max-width: ${BREAKPOINTS.Medium - 1}px)`); + + const keyboardShortcutModifierText = + Runtime.currentOS() === 'macOS' ? 'CMD' : 'CTRL'; + + const markdownSyntaxFormatters = { + ...coreSyntaxFormatters, + ...secondarySyntaxFormatters, + }; + + const keyboardShortcuts = Object.fromEntries( + Object.keys(markdownSyntaxFormatters) + .filter( + (syntaxName) => !!markdownSyntaxFormatters[syntaxName].keyboardShortcut, + ) + .map((syntaxName) => { + const { keyboardShortcut } = markdownSyntaxFormatters[syntaxName]; + return [keyboardShortcut, () => insertSyntax(syntaxName)]; + }), + ); + + useLayoutEffect(() => { + setTextArea(document.getElementById(textAreaId)); + }, [textAreaId]); + + useLayoutEffect(() => { + // If a user resizes their screen, make sure roving tabindex continues to operate + const focusableToolbarButton = document.querySelector( + '.toolbar-btn[tabindex="0"]', + ); + if (!focusableToolbarButton) { + document.querySelector('.toolbar-btn').setAttribute('tabindex', '0'); + } + }, [smallScreen]); + + useLayoutEffect(() => { + const clickOutsideHandler = ({ target }) => { + if (target.id !== 'overflow-menu-button') { + setOverflowMenuOpen(false); + } + }; + + const escapePressHandler = ({ key }) => { + if (key === 'Escape') { + setOverflowMenuOpen(false); + document.getElementById('overflow-menu-button').focus(); + } + if (key === 'Tab') { + setOverflowMenuOpen(false); + } + }; + + if (overflowMenuOpen) { + document + .getElementById('overflow-menu') + .getElementsByClassName('overflow-menu-btn')[0] + .focus(); + + document.addEventListener('keyup', escapePressHandler); + document.addEventListener('click', clickOutsideHandler); + } else { + document.removeEventListener('keyup', escapePressHandler); + document.removeEventListener('click', clickOutsideHandler); + } + + return () => { + document.removeEventListener('keyup', escapePressHandler); + document.removeEventListener('click', clickOutsideHandler); + }; + }, [overflowMenuOpen]); + + // Handles keyboard 'roving tabindex' pattern for toolbar + const handleToolbarButtonKeyPress = (event, className) => { + const { key, target } = event; + const { + nextElementSibling: nextButton, + previousElementSibling: previousButton, + } = target; + + switch (key) { + case 'ArrowRight': + event.preventDefault(); + target.setAttribute('tabindex', '-1'); + if (nextButton) { + nextButton.setAttribute('tabindex', 0); + nextButton.focus(); + } else { + const firstButton = document.querySelector(`.${className}`); + firstButton.setAttribute('tabindex', '0'); + firstButton.focus(); + } + break; + case 'ArrowLeft': + event.preventDefault(); + target.setAttribute('tabindex', '-1'); + if (previousButton) { + previousButton.setAttribute('tabindex', 0); + previousButton.focus(); + } else { + const allButtons = document.getElementsByClassName(className); + const lastButton = allButtons[allButtons.length - 1]; + lastButton.setAttribute('tabindex', '0'); + lastButton.focus(); + } + break; + case 'ArrowDown': + if (target.id === 'overflow-menu-button') { + event.preventDefault(); + setOverflowMenuOpen(true); + } + break; + } + }; + + const getSelectionData = (syntaxName) => { + const { + selectionStart: initialSelectionStart, + selectionEnd, + value, + } = textArea; + + let selectionStart = initialSelectionStart; + + // The 'heading' formatter can edit a previously inserted syntax, + // so we check if we need adjust the selection to the start of the line + if (syntaxName === 'heading') { + const indexOfLineStart = getIndexOfLineStart( + textArea.value, + initialSelectionStart, + ); + + if (textArea.value.charAt(indexOfLineStart + 1) === '#') { + selectionStart = indexOfLineStart; + } + } + + const textBeforeInsertion = value.substring(0, selectionStart); + const textAfterInsertion = value.substring(selectionEnd, value.length); + const selectedText = value.substring(selectionStart, selectionEnd); + + return { + textBeforeInsertion, + textAfterInsertion, + selectedText, + selectionStart, + selectionEnd, + }; + }; + + const insertSyntax = (syntaxName) => { + setOverflowMenuOpen(false); + + const { + textBeforeInsertion, + textAfterInsertion, + selectedText, + selectionStart, + selectionEnd, + } = getSelectionData(syntaxName); + + const { formattedText, cursorOffsetStart, cursorOffsetEnd } = + markdownSyntaxFormatters[syntaxName].getFormatting(selectedText); + + const newTextContent = `${textBeforeInsertion}${formattedText}${textAfterInsertion}`; + + textArea.value = newTextContent; + textArea.focus({ preventScroll: true }); + textArea.setSelectionRange( + selectionStart + cursorOffsetStart, + selectionEnd + cursorOffsetEnd, + ); + }; + + const getSecondaryFormatterButtons = (isOverflow) => + Object.keys(secondarySyntaxFormatters).map((controlName, index) => { + const { icon, label, keyboardShortcutKeys } = + secondarySyntaxFormatters[controlName]; + return ( +