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
This commit is contained in:
parent
e8d04165b4
commit
5069cd681a
25 changed files with 1197 additions and 52 deletions
|
|
@ -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);
|
||||
|
|
|
|||
4
app/assets/stylesheets/components/editor-toolbar.scss
Normal file
4
app/assets/stylesheets/components/editor-toolbar.scss
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.editor-toolbar {
|
||||
display: flex;
|
||||
background: var(--base-0);
|
||||
}
|
||||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
7
app/javascript/.storybook/preview-head.html
Normal file
7
app/javascript/.storybook/preview-head.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script>
|
||||
var Runtime = {
|
||||
currentOS: function () {
|
||||
return 'macOS';
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<Icon />
|
||||
)}
|
||||
{tooltip ? (
|
||||
<span
|
||||
className={`crayons-tooltip__content ${
|
||||
suppressTooltip ? 'crayons-tooltip__suppressed' : ''
|
||||
}`}
|
||||
>
|
||||
{tooltip}
|
||||
</span>
|
||||
) : null}
|
||||
</ComponentName>
|
||||
);
|
||||
};
|
||||
|
|
@ -162,4 +191,5 @@ Button.propTypes = {
|
|||
onBlur: PropTypes.func,
|
||||
tabIndex: PropTypes.number,
|
||||
title: PropTypes.string,
|
||||
tooltip: PropTypes.node,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -181,4 +181,11 @@ describe('<Button /> component', () => {
|
|||
|
||||
expect(someEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should render with a tooltip', () => {
|
||||
const { container } = render(
|
||||
<Button tooltip="tooltip text">Hello world!</Button>,
|
||||
);
|
||||
expect(container.innerHTML).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,3 +23,5 @@ exports[`<Button /> component should render an enabled button when using default
|
|||
exports[`<Button /> component should render an outlined button when using the variant "outlined" 1`] = `"<button class=\\"crayons-btn crayons-btn--outlined\\" type=\\"button\\">Hello world!</button>"`;
|
||||
|
||||
exports[`<Button /> component should render with a tabIndex 1`] = `"<button class=\\"crayons-btn\\" tabindex=\\"0\\" type=\\"button\\">Hello world!</button>"`;
|
||||
|
||||
exports[`<Button /> component should render with a tooltip 1`] = `"<button class=\\"crayons-btn crayons-tooltip__activator\\" type=\\"button\\">Hello world!<span class=\\"crayons-tooltip__content \\">tooltip text</span></button>"`;
|
||||
|
|
|
|||
314
app/javascript/crayons/MarkdownToolbar/MarkdownToolbar.jsx
Normal file
314
app/javascript/crayons/MarkdownToolbar/MarkdownToolbar.jsx
Normal file
|
|
@ -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 (
|
||||
<Button
|
||||
key={`${controlName}-btn`}
|
||||
role={isOverflow ? 'menuitem' : 'button'}
|
||||
variant="ghost"
|
||||
contentType="icon"
|
||||
icon={icon}
|
||||
className={
|
||||
isOverflow
|
||||
? 'overflow-menu-btn hidden m:block mr-2'
|
||||
: 'toolbar-btn m:hidden mr-2'
|
||||
}
|
||||
tabindex={isOverflow && index === 0 ? '0' : '-1'}
|
||||
onClick={() => insertSyntax(controlName)}
|
||||
onKeyUp={(e) =>
|
||||
handleToolbarButtonKeyPress(
|
||||
e,
|
||||
isOverflow ? 'overflow-menu-btn' : 'toolbar-btn',
|
||||
)
|
||||
}
|
||||
aria-label={label}
|
||||
tooltip={
|
||||
smallScreen ? null : (
|
||||
<span aria-hidden="true">
|
||||
{label}
|
||||
{keyboardShortcutKeys ? (
|
||||
<span className="opacity-75">
|
||||
{` ${keyboardShortcutModifierText} + ${keyboardShortcutKeys}`}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="editor-toolbar relative overflow-x-auto m:overflow-visible"
|
||||
aria-label="Markdown formatting toolbar"
|
||||
role="toolbar"
|
||||
aria-controls={textAreaId}
|
||||
>
|
||||
{Object.keys(coreSyntaxFormatters).map((controlName, index) => {
|
||||
const { icon, label, keyboardShortcutKeys } =
|
||||
coreSyntaxFormatters[controlName];
|
||||
return (
|
||||
<Button
|
||||
key={`${controlName}-btn`}
|
||||
variant="ghost"
|
||||
contentType="icon"
|
||||
icon={icon}
|
||||
className="toolbar-btn mr-2"
|
||||
tabindex={index === 0 ? '0' : '-1'}
|
||||
onClick={() => insertSyntax(controlName)}
|
||||
onKeyUp={(e) => handleToolbarButtonKeyPress(e, 'toolbar-btn')}
|
||||
aria-label={label}
|
||||
tooltip={
|
||||
smallScreen ? null : (
|
||||
<span aria-hidden="true">
|
||||
{label}
|
||||
{keyboardShortcutKeys ? (
|
||||
<span className="opacity-75">
|
||||
{` ${keyboardShortcutModifierText} + ${keyboardShortcutKeys}`}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{smallScreen ? getSecondaryFormatterButtons(false) : null}
|
||||
|
||||
{smallScreen ? null : (
|
||||
<Button
|
||||
id="overflow-menu-button"
|
||||
onClick={() => setOverflowMenuOpen(!overflowMenuOpen)}
|
||||
onKeyUp={(e) => handleToolbarButtonKeyPress(e, 'toolbar-btn')}
|
||||
aria-expanded={overflowMenuOpen ? 'true' : 'false'}
|
||||
aria-haspopup="true"
|
||||
variant="ghost"
|
||||
contentType="icon"
|
||||
icon={Overflow}
|
||||
className="toolbar-btn ml-auto hidden m:block"
|
||||
tabindex="-1"
|
||||
aria-label="More options"
|
||||
/>
|
||||
)}
|
||||
|
||||
{overflowMenuOpen && (
|
||||
<div
|
||||
id="overflow-menu"
|
||||
role="menu"
|
||||
className="crayons-dropdown flex p-2 min-w-unset right-0 top-100"
|
||||
>
|
||||
{getSecondaryFormatterButtons(true)}
|
||||
<Button
|
||||
tagName="a"
|
||||
role="menuitem"
|
||||
url="/p/editor_guide"
|
||||
variant="ghost"
|
||||
contentType="icon"
|
||||
icon={Help}
|
||||
className="overflow-menu-btn"
|
||||
tabindex="-1"
|
||||
aria-label="Help"
|
||||
onKeyUp={(e) => handleToolbarButtonKeyPress(e, 'overflow-menu-btn')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{textArea && (
|
||||
<KeyboardShortcuts
|
||||
shortcuts={keyboardShortcuts}
|
||||
eventTarget={textArea}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { h } from 'preact';
|
||||
import { MarkdownToolbar } from '@crayons';
|
||||
|
||||
export default {
|
||||
title: 'App Components/MarkdownToolbar',
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: 'var(--su-2) var(--content-padding-x)',
|
||||
background: 'var(--base-0)',
|
||||
}}
|
||||
>
|
||||
<MarkdownToolbar textAreaId="text-area" />
|
||||
</div>
|
||||
<textarea
|
||||
id="text-area"
|
||||
aria-label="Editor"
|
||||
className="crayons-textfield min-h-full border-0 radius-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Default.story = {
|
||||
name: 'MarkdownToolbar',
|
||||
};
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { h } from 'preact';
|
||||
import { render, waitFor } from '@testing-library/preact';
|
||||
import { axe } from 'jest-axe';
|
||||
import '@testing-library/jest-dom';
|
||||
import { MarkdownToolbar } from '../MarkdownToolbar';
|
||||
|
||||
describe('<MarkdownToolbar />', () => {
|
||||
beforeEach(() => {
|
||||
global.Runtime = {
|
||||
currentOS: jest.fn(() => 'macOS'),
|
||||
};
|
||||
|
||||
global.window.matchMedia = jest.fn((query) => {
|
||||
return {
|
||||
matches: false,
|
||||
media: query,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it('should have no a11y violations when rendered', async () => {
|
||||
const { container } = render(<MarkdownToolbar />);
|
||||
const results = await axe(container);
|
||||
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should render core syntax formatters in main toolbar', () => {
|
||||
const { getByLabelText } = render(<MarkdownToolbar />);
|
||||
|
||||
expect(getByLabelText('Bold')).toBeInTheDocument();
|
||||
expect(getByLabelText('Italic')).toBeInTheDocument();
|
||||
expect(getByLabelText('Ordered list')).toBeInTheDocument();
|
||||
expect(getByLabelText('Unordered list')).toBeInTheDocument();
|
||||
expect(getByLabelText('Heading')).toBeInTheDocument();
|
||||
expect(getByLabelText('Quote')).toBeInTheDocument();
|
||||
expect(getByLabelText('Code')).toBeInTheDocument();
|
||||
expect(getByLabelText('Code block')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render an overflow menu with secondary formatters and help link', async () => {
|
||||
const { getByLabelText } = render(<MarkdownToolbar />);
|
||||
|
||||
getByLabelText('More options').click();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByLabelText('Underline')).toBeInTheDocument(),
|
||||
);
|
||||
expect(getByLabelText('Strikethrough')).toBeInTheDocument();
|
||||
expect(getByLabelText('Line divider')).toBeInTheDocument();
|
||||
expect(getByLabelText('Help')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
import {
|
||||
coreSyntaxFormatters,
|
||||
secondarySyntaxFormatters,
|
||||
} from '../markdownSyntaxFormatters';
|
||||
|
||||
describe('markdownSntaxFormatters', () => {
|
||||
const exampleTextSelection = 'selection';
|
||||
|
||||
it('formats bold text', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['bold'].getFormatting(exampleTextSelection),
|
||||
).toEqual({
|
||||
formattedText: '**selection**',
|
||||
cursorOffsetStart: 2,
|
||||
cursorOffsetEnd: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats italic text', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['italic'].getFormatting(exampleTextSelection),
|
||||
).toEqual({
|
||||
formattedText: '_selection_',
|
||||
cursorOffsetStart: 1,
|
||||
cursorOffsetEnd: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats a link with an empty selection', () => {
|
||||
expect(coreSyntaxFormatters['link'].getFormatting('')).toEqual({
|
||||
formattedText: '[](url)',
|
||||
cursorOffsetStart: 1,
|
||||
cursorOffsetEnd: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats a link with a non-URL selection', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['link'].getFormatting(exampleTextSelection),
|
||||
).toEqual({
|
||||
formattedText: '[selection](url)',
|
||||
cursorOffsetStart: 12,
|
||||
cursorOffsetEnd: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats a link with an http URL selection', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['link'].getFormatting('http://myurl.com'),
|
||||
).toEqual({
|
||||
formattedText: '[](http://myurl.com)',
|
||||
cursorOffsetStart: 1,
|
||||
cursorOffsetEnd: -15,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats a link with an https URL selection', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['link'].getFormatting('https://myurl.com'),
|
||||
).toEqual({
|
||||
formattedText: '[](https://myurl.com)',
|
||||
cursorOffsetStart: 1,
|
||||
cursorOffsetEnd: -16,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats an unordered list from an empty selection', () => {
|
||||
expect(coreSyntaxFormatters['unorderedList'].getFormatting('')).toEqual({
|
||||
formattedText: '\n- ',
|
||||
cursorOffsetStart: 3,
|
||||
cursorOffsetEnd: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats an unordered list from a single line selection', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['unorderedList'].getFormatting(exampleTextSelection),
|
||||
).toEqual({
|
||||
formattedText: '\n- selection\n',
|
||||
cursorOffsetStart: 1,
|
||||
cursorOffsetEnd: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats an unordered list from a multi-line selection', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['unorderedList'].getFormatting('one\ntwo'),
|
||||
).toEqual({
|
||||
formattedText: '\n- one\n- two\n',
|
||||
cursorOffsetStart: 1,
|
||||
cursorOffsetEnd: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats an ordered list from an empty selection', () => {
|
||||
expect(coreSyntaxFormatters['orderedList'].getFormatting('')).toEqual({
|
||||
formattedText: '\n1. ',
|
||||
cursorOffsetStart: 4,
|
||||
cursorOffsetEnd: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats an ordered list from a single line selection', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['orderedList'].getFormatting(exampleTextSelection),
|
||||
).toEqual({
|
||||
formattedText: '\n1. selection\n',
|
||||
cursorOffsetStart: 1,
|
||||
cursorOffsetEnd: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats an ordered list from a multi-line selection', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['orderedList'].getFormatting('one\ntwo'),
|
||||
).toEqual({
|
||||
formattedText: '\n1. one\n2. two\n',
|
||||
cursorOffsetStart: 1,
|
||||
cursorOffsetEnd: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('Formats a heading from an empty selection', () => {
|
||||
expect(coreSyntaxFormatters['heading'].getFormatting('')).toEqual({
|
||||
formattedText: '\n## \n',
|
||||
cursorOffsetStart: 4,
|
||||
cursorOffsetEnd: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('Formats a heading from a selection with no heading level', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['heading'].getFormatting(exampleTextSelection),
|
||||
).toEqual({
|
||||
formattedText: '\n## selection\n',
|
||||
cursorOffsetStart: 4,
|
||||
cursorOffsetEnd: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('Formats a heading from a selection with a heading level 2', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['heading'].getFormatting('## selection'),
|
||||
).toEqual({
|
||||
formattedText: '### selection',
|
||||
cursorOffsetStart: 4,
|
||||
cursorOffsetEnd: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('Formats a heading from a selection with a heading level 3', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['heading'].getFormatting('### selection'),
|
||||
).toEqual({
|
||||
formattedText: '#### selection',
|
||||
cursorOffsetStart: 5,
|
||||
cursorOffsetEnd: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('Formats a heading from a selection with a heading level 4 by returning same selection', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['heading'].getFormatting('#### selection'),
|
||||
).toEqual({
|
||||
formattedText: '#### selection',
|
||||
cursorOffsetStart: 5,
|
||||
cursorOffsetEnd: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats a quote with empty selection', () => {
|
||||
expect(coreSyntaxFormatters['quote'].getFormatting('')).toEqual({
|
||||
formattedText: '\n> \n',
|
||||
cursorOffsetStart: 3,
|
||||
cursorOffsetEnd: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats a quote on a single-line selection', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['quote'].getFormatting(exampleTextSelection),
|
||||
).toEqual({
|
||||
formattedText: '\n> selection\n',
|
||||
cursorOffsetStart: 3,
|
||||
cursorOffsetEnd: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats a quote on a multi-line selection', () => {
|
||||
expect(coreSyntaxFormatters['quote'].getFormatting('one\ntwo')).toEqual({
|
||||
formattedText: '\n> one\n> two\n',
|
||||
cursorOffsetStart: 3,
|
||||
cursorOffsetEnd: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats inline code', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['code'].getFormatting(exampleTextSelection),
|
||||
).toEqual({
|
||||
formattedText: '`selection`',
|
||||
cursorOffsetStart: 1,
|
||||
cursorOffsetEnd: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats a code block', () => {
|
||||
expect(
|
||||
coreSyntaxFormatters['codeBlock'].getFormatting(exampleTextSelection),
|
||||
).toEqual({
|
||||
formattedText: '\n```\nselection\n```\n',
|
||||
cursorOffsetStart: 5,
|
||||
cursorOffsetEnd: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats underline text', () => {
|
||||
expect(
|
||||
secondarySyntaxFormatters['underline'].getFormatting(
|
||||
exampleTextSelection,
|
||||
),
|
||||
).toEqual({
|
||||
formattedText: '<u>selection</u>',
|
||||
cursorOffsetStart: 3,
|
||||
cursorOffsetEnd: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats strikethrough text', () => {
|
||||
expect(
|
||||
secondarySyntaxFormatters['strikethrough'].getFormatting(
|
||||
exampleTextSelection,
|
||||
),
|
||||
).toEqual({
|
||||
formattedText: '~~selection~~',
|
||||
cursorOffsetStart: 2,
|
||||
cursorOffsetEnd: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a line divider when selection is empty', () => {
|
||||
expect(secondarySyntaxFormatters['divider'].getFormatting('')).toEqual({
|
||||
formattedText: '\n---\n',
|
||||
cursorOffsetStart: 5,
|
||||
cursorOffsetEnd: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a line divider after given selection ', () => {
|
||||
expect(
|
||||
secondarySyntaxFormatters['divider'].getFormatting(exampleTextSelection),
|
||||
).toEqual({
|
||||
formattedText: 'selection\n---\n',
|
||||
cursorOffsetStart: 14,
|
||||
cursorOffsetEnd: 14,
|
||||
});
|
||||
});
|
||||
});
|
||||
185
app/javascript/crayons/MarkdownToolbar/icons.jsx
Normal file
185
app/javascript/crayons/MarkdownToolbar/icons.jsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { h } from 'preact';
|
||||
|
||||
export const Bold = () => (
|
||||
<svg
|
||||
className="crayons-icon"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="m8 11h4.5c.663 0 1.2989-.2634 1.7678-.7322.4688-.46887.7322-1.10476.7322-1.7678s-.2634-1.29893-.7322-1.76777c-.4689-.46884-1.1048-.73223-1.7678-.73223h-4.5zm10 4.5c0 .5909-.1164 1.1761-.3425 1.7221-.2262.5459-.5577 1.042-.9755 1.4599-.4179.4178-.914.7493-1.4599.9755-.546.2261-1.1312.3425-1.7221.3425h-7.5v-16h6.5c.881.00004 1.7425.25865 2.4779.74378.7353.48512 1.3121 1.17542 1.6588 1.98529s.448 1.70369.2915 2.57062c-.1566.86691-.5641 1.66881-1.1722 2.30631.6826.3953 1.2493.9632 1.6432 1.6466.3939.6835.6011 1.4586.6008 2.2474zm-10-2.5v5h5.5c.663 0 1.2989-.2634 1.7678-.7322.4688-.4689.7322-1.1048.7322-1.7678s-.2634-1.2989-.7322-1.7678c-.4689-.4688-1.1048-.7322-1.7678-.7322z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Italic = () => (
|
||||
<svg
|
||||
className="crayons-icon"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15 20H7V18H9.927L12.043 6H9V4H17V6H14.073L11.957 18H15V20Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Link = () => (
|
||||
<svg
|
||||
className="crayons-icon"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="m18.364 15.536-1.414-1.416 1.414-1.414c.4676-.4636.8391-1.0149 1.0931-1.6224.2539-.6075.3854-1.2592.3869-1.91765.0014-.65845-.1272-1.3107-.3785-1.9193-.2513-.60861-.6204-1.16158-1.086-1.62718s-1.0186-.83464-1.6272-1.08596c-.6086-.25131-1.2608-.37994-1.9193-.37849-.6584.00144-1.3101.13292-1.9176.3869-.6075.25397-1.1589.62544-1.6224 1.09307l-1.414 1.415-1.415-1.414 1.416-1.414c1.3128-1.31282 3.0934-2.05036 4.95-2.05036s3.6372.73754 4.95 2.05036 2.0504 3.09339 2.0504 4.95-.7376 3.63721-2.0504 4.95001l-1.415 1.414zm-2.828 2.828-1.415 1.414c-1.3128 1.3128-3.0934 2.0503-4.95 2.0503-1.85661 0-3.63718-.7375-4.95-2.0503-1.31283-1.3128-2.05036-3.0934-2.05036-4.95s.73753-3.6372 2.05036-4.95001l1.415-1.414 1.414 1.416-1.414 1.41401c-.46763.4635-.8391 1.0149-1.09308 1.6224-.25397.6075-.38545 1.2592-.3869 1.9176-.00144.6585.12719 1.3107.3785 1.9193s.62036 1.1616 1.08596 1.6272c.46559.4656 1.01857.8347 1.62717 1.086.60861.2513 1.26086.3799 1.91931.3785.65845-.0015 1.31014-.133 1.91764-.3869.6075-.254 1.1588-.6255 1.6224-1.0931l1.414-1.414 1.415 1.414zm-.708-10.60701 1.415 1.415-7.071 7.07001-1.415-1.414 7.071-7.07001z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const OrderedList = () => (
|
||||
<svg
|
||||
className="crayons-icon"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="m8 4h13v2h-13zm-3-1v3h1v1h-3v-1h1v-2h-1v-1zm-2 11v-2.5h2v-.5h-2v-1h3v2.5h-2v.5h2v1zm2 5.5h-2v-1h2v-.5h-2v-1h3v4h-3v-1h2zm3-8.5h13v2h-13zm0 7h13v2h-13z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const UnorderedList = () => (
|
||||
<svg
|
||||
className="crayons-icon"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="m8 4h13v2h-13zm-3.5 2.5c-.39782 0-.77936-.15804-1.06066-.43934s-.43934-.66284-.43934-1.06066.15804-.77936.43934-1.06066.66284-.43934 1.06066-.43934.77936.15804 1.06066.43934.43934.66284.43934 1.06066-.15804.77936-.43934 1.06066-.66284.43934-1.06066.43934zm0 7c-.39782 0-.77936-.158-1.06066-.4393s-.43934-.6629-.43934-1.0607.15804-.7794.43934-1.0607.66284-.4393 1.06066-.4393.77936.158 1.06066.4393.43934.6629.43934 1.0607-.15804.7794-.43934 1.0607-.66284.4393-1.06066.4393zm0 6.9c-.39782 0-.77936-.158-1.06066-.4393s-.43934-.6629-.43934-1.0607.15804-.7794.43934-1.0607.66284-.4393 1.06066-.4393.77936.158 1.06066.4393.43934.6629.43934 1.0607-.15804.7794-.43934 1.0607-.66284.4393-1.06066.4393zm3.5-9.4h13v2h-13zm0 7h13v2h-13z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Heading = () => (
|
||||
<svg
|
||||
className="crayons-icon"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="m17 11v-7h2v17h-2v-8h-10v8h-2v-17h2v7z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Quote = () => (
|
||||
<svg
|
||||
className="crayons-icon"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="m4.583 17.321c-1.03-1.094-1.583-2.321-1.583-4.31 0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.804.167 3.226 1.648 3.226 3.489 0 .9283-.3687 1.8185-1.02513 2.4749-.65637.6563-1.54661 1.0251-2.47487 1.0251-1.073 0-2.099-.49-2.748-1.179zm10 0c-1.03-1.094-1.583-2.321-1.583-4.31 0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.804.167 3.226 1.648 3.226 3.489 0 .9283-.3687 1.8185-1.0251 2.4749-.6564.6563-1.5466 1.0251-2.4749 1.0251-1.073 0-2.099-.49-2.748-1.179z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Code = () => (
|
||||
<svg
|
||||
className="crayons-icon"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="m23 12-7.071 7.071-1.414-1.414 5.657-5.657-5.657-5.65704 1.414-1.414zm-19.172 0 5.657 5.657-1.414 1.414-7.071-7.071 7.071-7.07104 1.414 1.414z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CodeBlock = () => (
|
||||
<svg
|
||||
className="crayons-icon"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="m3 3h18c.2652 0 .5196.10536.7071.29289.1875.18754.2929.44189.2929.70711v16c0 .2652-.1054.5196-.2929.7071s-.4419.2929-.7071.2929h-18c-.26522 0-.51957-.1054-.70711-.2929-.18753-.1875-.29289-.4419-.29289-.7071v-16c0-.26522.10536-.51957.29289-.70711.18754-.18753.44189-.29289.70711-.29289zm1 2v14h16v-14zm15 7-3.536 3.536-1.414-1.415 2.122-2.121-2.122-2.121 1.414-1.415zm-11.172 0 2.122 2.121-1.414 1.415-3.536-3.536 3.536-3.536 1.414 1.416z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Overflow = () => (
|
||||
<svg
|
||||
className="crayons-icon"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="m12 17c1.1046 0 2 .8954 2 2s-.8954 2-2 2-2-.8954-2-2 .8954-2 2-2zm0-7c1.1046 0 2 .8954 2 2s-.8954 2-2 2-2-.8954-2-2 .8954-2 2-2zm2-5c0-1.10457-.8954-2-2-2s-2 .89543-2 2 .8954 2 2 2 2-.89543 2-2z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Underline = () => (
|
||||
<svg
|
||||
className="crayons-icon"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="m8 3v9c0 1.0609.42143 2.0783 1.17157 2.8284.75015.7502 1.76753 1.1716 2.82843 1.1716s2.0783-.4214 2.8284-1.1716c.7502-.7501 1.1716-1.7675 1.1716-2.8284v-9h2v9c0 1.5913-.6321 3.1174-1.7574 4.2426-1.1252 1.1253-2.6513 1.7574-4.2426 1.7574s-3.11742-.6321-4.24264-1.7574c-1.12522-1.1252-1.75736-2.6513-1.75736-4.2426v-9zm-4 17h16v2h-16z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Strikethrough = () => (
|
||||
<svg
|
||||
className="crayons-icon"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="m17.154 14c.23.516.346 1.09.346 1.72 0 1.342-.524 2.392-1.571 3.147-1.049.755-2.496 1.133-4.343 1.133-1.64 0-3.263-.381-4.87-1.144v-2.256c1.52.877 3.075 1.316 4.666 1.316 2.551 0 3.83-.732 3.839-2.197.0053-.297-.0494-.5921-.1607-.8675-.1114-.2754-.2771-.5256-.4873-.7355l-.12-.117h-11.453v-2h18v2h-3.846zm-4.078-3h-5.447c-.17517-.1597-.33611-.3344-.481-.522-.432-.558-.648-1.232-.648-2.026 0-1.236.466-2.287 1.397-3.153.933-.866 2.374-1.299 4.325-1.299 1.471 0 2.879.328 4.222.984v2.152c-1.2-.687-2.515-1.03-3.946-1.03-2.48 0-3.719.782-3.719 2.346 0 .42.218.786.654 1.099s.974.562 1.613.75c.62.18 1.297.414 2.03.699z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Divider = () => (
|
||||
<svg
|
||||
className="crayons-icon"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path d="m2 11h6v2h-6z" />
|
||||
<path d="m2 11h6v2h-6z" />
|
||||
<path d="m9 11h6v2h-6z" />
|
||||
<path d="m16 11h6v2h-6z" />
|
||||
<g clip-rule="evenodd" fill-rule="evenodd">
|
||||
<path d="m12 6.58574-2.29288-2.29289-1.41421 1.41421 3.70709 3.70711 3.7071-3.70711-1.4142-1.41421z" />
|
||||
<path d="m12 17.4143-2.29288 2.2929-1.41421-1.4143 3.70709-3.7071 3.7071 3.7071-1.4142 1.4143z" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Help = () => (
|
||||
<svg
|
||||
className="crayons-icon"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="m12 22c-5.523 0-10-4.477-10-10s4.477-10 10-10 10 4.477 10 10-4.477 10-10 10zm0-2c2.1217 0 4.1566-.8429 5.6569-2.3431 1.5002-1.5003 2.3431-3.5352 2.3431-5.6569 0-2.12173-.8429-4.15656-2.3431-5.65685-1.5003-1.5003-3.5352-2.34315-5.6569-2.34315-2.12173 0-4.15656.84285-5.65685 2.34315-1.5003 1.50029-2.34315 3.53512-2.34315 5.65685 0 2.1217.84285 4.1566 2.34315 5.6569 1.50029 1.5002 3.53512 2.3431 5.65685 2.3431zm-1-5h2v2h-2zm2-1.645v.645h-2v-1.5c0-.2652.1054-.5196.2929-.7071s.4419-.2929.7071-.2929c.2841 0 .5623-.0807.8023-.2327.24-.1519.432-.3689.5535-.6257s.1676-.5428.1329-.82476c-.0347-.28195-.1487-.54826-.3289-.76793-.1801-.21967-.4189-.38368-.6886-.47294s-.5592-.1001-.8348-.03126c-.2756.06883-.526.21452-.722.42011-.1961.20558-.3297.46261-.3854.74118l-1.962-.393c.12163-.60792.40251-1.17263.81392-1.63641.41141-.46379.93858-.81001 1.52768-1.00327s1.2189-.22663 1.8251-.09671c.6062.12993 1.167.4185 1.6251.83621.4581.41772.7971.94959.9823 1.54124.1852.59166.21 1.22184.0718 1.82624s-.4344 1.1612-.8584 1.6136c-.4239.4523-.9604.784-1.5545.9611z" />
|
||||
</svg>
|
||||
);
|
||||
1
app/javascript/crayons/MarkdownToolbar/index.js
Normal file
1
app/javascript/crayons/MarkdownToolbar/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './MarkdownToolbar';
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Link,
|
||||
OrderedList,
|
||||
UnorderedList,
|
||||
Heading,
|
||||
Quote,
|
||||
Code,
|
||||
CodeBlock,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
Divider,
|
||||
} from './icons';
|
||||
|
||||
const isStringStartAUrl = (string) => {
|
||||
const startingText = string.substring(0, 8);
|
||||
return startingText === 'https://' || startingText.startsWith('http://');
|
||||
};
|
||||
|
||||
export const coreSyntaxFormatters = {
|
||||
bold: {
|
||||
icon: Bold,
|
||||
label: 'Bold',
|
||||
keyboardShortcut: 'ctrl+b',
|
||||
keyboardShortcutKeys: `B`,
|
||||
getFormatting: (selection) => ({
|
||||
formattedText: `**${selection}**`,
|
||||
cursorOffsetStart: 2,
|
||||
cursorOffsetEnd: 2,
|
||||
}),
|
||||
},
|
||||
italic: {
|
||||
icon: Italic,
|
||||
label: 'Italic',
|
||||
keyboardShortcut: 'ctrl+i',
|
||||
keyboardShortcutKeys: `I`,
|
||||
getFormatting: (selection) => ({
|
||||
formattedText: `_${selection}_`,
|
||||
cursorOffsetStart: 1,
|
||||
cursorOffsetEnd: 1,
|
||||
}),
|
||||
},
|
||||
link: {
|
||||
icon: Link,
|
||||
label: 'Link',
|
||||
keyboardShortcut: 'ctrl+k',
|
||||
keyboardShortcutKeys: `K`,
|
||||
getFormatting: (selection) => {
|
||||
const isUrl = isStringStartAUrl(selection);
|
||||
const selectionLength = selection.length;
|
||||
|
||||
if (selectionLength === 0) {
|
||||
return {
|
||||
formattedText: '[](url)',
|
||||
cursorOffsetStart: 1,
|
||||
cursorOffsetEnd: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
formattedText: isUrl ? `[](${selection})` : `[${selection}](url)`,
|
||||
cursorOffsetStart: isUrl ? 1 : selectionLength + 3,
|
||||
cursorOffsetEnd: isUrl ? -1 * (selectionLength - 1) : 6,
|
||||
};
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
icon: OrderedList,
|
||||
label: 'Ordered list',
|
||||
getFormatting: (selection) => {
|
||||
let newString = `\n${selection
|
||||
.split('\n')
|
||||
.map((textChunk, index) => `${index + 1}. ${textChunk}`)
|
||||
.join('\n')}`;
|
||||
|
||||
if (selection !== '') {
|
||||
newString += '\n';
|
||||
}
|
||||
|
||||
return {
|
||||
formattedText: newString,
|
||||
cursorOffsetStart: selection.length === 0 ? 4 : 1,
|
||||
cursorOffsetEnd: newString.length - selection.length,
|
||||
};
|
||||
},
|
||||
},
|
||||
unorderedList: {
|
||||
icon: UnorderedList,
|
||||
label: 'Unordered list',
|
||||
getFormatting: (selection) => {
|
||||
const bulletedSelection = selection.replace(/\n/g, '\n- ');
|
||||
let newString = `\n- ${bulletedSelection}`;
|
||||
|
||||
if (selection !== '') {
|
||||
newString += '\n';
|
||||
}
|
||||
|
||||
return {
|
||||
formattedText: newString,
|
||||
cursorOffsetStart: selection.length === 0 ? 3 : 1,
|
||||
cursorOffsetEnd: newString.length - selection.length,
|
||||
};
|
||||
},
|
||||
},
|
||||
heading: {
|
||||
icon: Heading,
|
||||
label: 'Heading',
|
||||
getFormatting: (selection) => {
|
||||
let currentHeadingIndex = 0;
|
||||
while (selection.charAt(currentHeadingIndex) === '#') {
|
||||
currentHeadingIndex++;
|
||||
}
|
||||
|
||||
// Only allow up to h4
|
||||
if (currentHeadingIndex === 4) {
|
||||
return {
|
||||
formattedText: selection,
|
||||
cursorOffsetStart: 5,
|
||||
cursorOffsetEnd: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const adjustingHeading = currentHeadingIndex > 0;
|
||||
|
||||
return {
|
||||
formattedText: adjustingHeading
|
||||
? `#${selection}`
|
||||
: `\n## ${selection}\n`,
|
||||
cursorOffsetStart: adjustingHeading ? currentHeadingIndex + 2 : 4,
|
||||
cursorOffsetEnd: adjustingHeading ? 1 : 4,
|
||||
};
|
||||
},
|
||||
},
|
||||
quote: {
|
||||
icon: Quote,
|
||||
label: 'Quote',
|
||||
getFormatting: (selection) => {
|
||||
const quotedSelection = selection.replace(/\n/g, '\n> ');
|
||||
const newString = `\n> ${quotedSelection}\n`;
|
||||
return {
|
||||
formattedText: newString,
|
||||
cursorOffsetStart: 3,
|
||||
cursorOffsetEnd:
|
||||
selection === '' ? 3 : newString.length - selection.length,
|
||||
};
|
||||
},
|
||||
},
|
||||
code: {
|
||||
icon: Code,
|
||||
label: 'Code',
|
||||
getFormatting: (selection) => ({
|
||||
formattedText: `\`${selection}\``,
|
||||
cursorOffsetStart: 1,
|
||||
cursorOffsetEnd: 1,
|
||||
}),
|
||||
},
|
||||
codeBlock: {
|
||||
icon: CodeBlock,
|
||||
label: 'Code block',
|
||||
getFormatting: (selection) => ({
|
||||
formattedText: `\n\`\`\`\n${selection}\n\`\`\`\n`,
|
||||
cursorOffsetStart: 5,
|
||||
cursorOffsetEnd: 5,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const secondarySyntaxFormatters = {
|
||||
underline: {
|
||||
icon: Underline,
|
||||
label: 'Underline',
|
||||
keyboardShortcut: 'ctrl+u',
|
||||
keyboardShortcutKeys: `U`,
|
||||
getFormatting: (selection) => ({
|
||||
formattedText: `<u>${selection}</u>`,
|
||||
cursorOffsetStart: 3,
|
||||
cursorOffsetEnd: 3,
|
||||
}),
|
||||
},
|
||||
strikethrough: {
|
||||
icon: Strikethrough,
|
||||
label: 'Strikethrough',
|
||||
keyboardShortcut: 'ctrl+shift+x',
|
||||
keyboardShortcutKeys: `SHIFT + X`,
|
||||
getFormatting: (selection) => ({
|
||||
formattedText: `~~${selection}~~`,
|
||||
cursorOffsetStart: 2,
|
||||
cursorOffsetEnd: 2,
|
||||
}),
|
||||
},
|
||||
divider: {
|
||||
icon: Divider,
|
||||
label: 'Line divider',
|
||||
getFormatting: (selection) => ({
|
||||
formattedText: `${selection}\n---\n`,
|
||||
cursorOffsetStart: selection.length + 5,
|
||||
cursorOffsetEnd: selection.length + 5,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
@ -6,3 +6,4 @@ export * from '@crayons/Modal';
|
|||
export * from '@crayons/Spinner';
|
||||
export * from '@crayons/MobileDrawer';
|
||||
export * from '@crayons/navigation';
|
||||
export * from '@crayons/MarkdownToolbar';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getMentionWordData } from '../textAreaUtils';
|
||||
import { getMentionWordData, getIndexOfLineStart } from '../textAreaUtils';
|
||||
|
||||
describe('getMentionWordData', () => {
|
||||
it('returns userMention false for cursor at start of input', () => {
|
||||
|
|
@ -7,9 +7,8 @@ describe('getMentionWordData', () => {
|
|||
value: 'text with @mention',
|
||||
};
|
||||
|
||||
const { isUserMention, indexOfMentionStart } = getMentionWordData(
|
||||
inputState,
|
||||
);
|
||||
const { isUserMention, indexOfMentionStart } =
|
||||
getMentionWordData(inputState);
|
||||
expect(isUserMention).toBe(false);
|
||||
expect(indexOfMentionStart).toEqual(-1);
|
||||
});
|
||||
|
|
@ -20,9 +19,8 @@ describe('getMentionWordData', () => {
|
|||
value: '',
|
||||
};
|
||||
|
||||
const { isUserMention, indexOfMentionStart } = getMentionWordData(
|
||||
inputState,
|
||||
);
|
||||
const { isUserMention, indexOfMentionStart } =
|
||||
getMentionWordData(inputState);
|
||||
expect(isUserMention).toBe(false);
|
||||
expect(indexOfMentionStart).toEqual(-1);
|
||||
});
|
||||
|
|
@ -33,9 +31,8 @@ describe('getMentionWordData', () => {
|
|||
value: 'text with no mention',
|
||||
};
|
||||
|
||||
const { isUserMention, indexOfMentionStart } = getMentionWordData(
|
||||
inputState,
|
||||
);
|
||||
const { isUserMention, indexOfMentionStart } =
|
||||
getMentionWordData(inputState);
|
||||
expect(isUserMention).toBe(false);
|
||||
expect(indexOfMentionStart).toEqual(-1);
|
||||
});
|
||||
|
|
@ -46,9 +43,8 @@ describe('getMentionWordData', () => {
|
|||
value: '@mention',
|
||||
};
|
||||
|
||||
const { isUserMention, indexOfMentionStart } = getMentionWordData(
|
||||
inputState,
|
||||
);
|
||||
const { isUserMention, indexOfMentionStart } =
|
||||
getMentionWordData(inputState);
|
||||
expect(isUserMention).toBe(true);
|
||||
expect(indexOfMentionStart).toEqual(0);
|
||||
});
|
||||
|
|
@ -59,10 +55,23 @@ describe('getMentionWordData', () => {
|
|||
value: 'text with @mention',
|
||||
};
|
||||
|
||||
const { isUserMention, indexOfMentionStart } = getMentionWordData(
|
||||
inputState,
|
||||
);
|
||||
const { isUserMention, indexOfMentionStart } =
|
||||
getMentionWordData(inputState);
|
||||
expect(isUserMention).toBe(true);
|
||||
expect(indexOfMentionStart).toEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIndexOfLineStart', () => {
|
||||
it('returns 0 for empty text', () => {
|
||||
expect(getIndexOfLineStart('', 0)).toEqual(0);
|
||||
});
|
||||
|
||||
it('returns start index of 0 for a single line of text', () => {
|
||||
expect(getIndexOfLineStart('something', 5)).toEqual(0);
|
||||
});
|
||||
|
||||
it('returns start index of line for a multi-line text', () => {
|
||||
expect(getIndexOfLineStart('one\ntwo', 6)).toEqual(4);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -128,9 +128,8 @@ const getIndexOfCurrentWordAutocompleteSymbol = (content, selectionIndex) => {
|
|||
*/
|
||||
export const useTextAreaAutoResize = () => {
|
||||
const [textArea, setTextArea] = useState(null);
|
||||
const [constrainToContentHeight, setConstrainToContentHeight] = useState(
|
||||
false,
|
||||
);
|
||||
const [constrainToContentHeight, setConstrainToContentHeight] =
|
||||
useState(false);
|
||||
const [additionalElements, setAdditionalElements] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -167,3 +166,23 @@ export const useTextAreaAutoResize = () => {
|
|||
|
||||
return { setTextArea, setAdditionalElements, setConstrainToContentHeight };
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to return the index of the current line's starting point
|
||||
*
|
||||
* @param {string} text The text value of the textArea
|
||||
* @param {number} cursorStart The current position of the user's cursor
|
||||
* @returns
|
||||
*/
|
||||
export const getIndexOfLineStart = (text, cursorStart) => {
|
||||
const currentCharacter = text.charAt(cursorStart - 1);
|
||||
if (currentCharacter === '\n') {
|
||||
return cursorStart;
|
||||
}
|
||||
|
||||
if (cursorStart !== 0) {
|
||||
return getIndexOfLineStart(text, cursorStart - 1);
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
General settings
|
||||
</h3>
|
||||
<div
|
||||
class="crayons-field crayons-field--checkbox <%= invite_only_mode_or_no_enabled_auth_options ? "crayons-tooltip" : "" %>"
|
||||
class="crayons-field crayons-field--checkbox <%= invite_only_mode_or_no_enabled_auth_options ? "crayons-hover-tooltip" : "" %>"
|
||||
data-tooltip="Unchecking this will enable Email Registration">
|
||||
<%= f.check_box :invite_only_mode,
|
||||
checked: invite_only_mode_or_no_enabled_auth_options,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<a href="<%= URL.user(comment.user) %>" class="crayons-link crayons-link--secondary flex items-center fw-medium m:hidden">
|
||||
<span class="js-comment-username"><%= comment.user.name %></span>
|
||||
<% if commentable_author_is_op?(commentable, comment) %>
|
||||
<span class="crayons-tooltip inline-block spec-op-author -mr-2" data-tooltip="<%= get_ama_or_op_banner(commentable) %>">
|
||||
<span class="crayons-hover-tooltip inline-block spec-op-author -mr-2" data-tooltip="<%= get_ama_or_op_banner(commentable) %>">
|
||||
<%= inline_svg_tag("small-medal.svg", aria: true, class: "crayons-icon", title: get_ama_or_op_banner(commentable)) %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
</button>
|
||||
<%= render "/shared/profile_preview_card", actor: comment.user, id: "comment-profile-preview-content-#{comment.id}" %>
|
||||
<% if commentable_author_is_op?(commentable, comment) %>
|
||||
<span class="crayons-tooltip inline-block spec-op-author -ml-2" data-tooltip="<%= get_ama_or_op_banner(commentable) %>">
|
||||
<span class="crayons-hover-tooltip inline-block spec-op-author -ml-2" data-tooltip="<%= get_ama_or_op_banner(commentable) %>">
|
||||
<%= inline_svg_tag("small-medal.svg", aria: true, class: "crayons-icon", title: get_ama_or_op_banner(commentable)) %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@
|
|||
<div class="crayons-field mt-2">
|
||||
<%= f.label :name, class: "crayons-field__label", aria: { label: "Name" } do %>
|
||||
Name
|
||||
<span class="crayons-field__required crayons-tooltip" data-tooltip="* - required">*</span>
|
||||
<span class="crayons-field__required crayons-hover-tooltip" data-tooltip="* - required">*</span>
|
||||
<% end %>
|
||||
<%= f.text_field :name, class: "crayons-textfield", required: true %>
|
||||
</div>
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
<div class="crayons-field mt-2">
|
||||
<%= f.label :username, class: "crayons-field__label", aria: { label: "Username" } do %>
|
||||
Username
|
||||
<span class="crayons-field__required crayons-tooltip" data-tooltip="* - required">*</span>
|
||||
<span class="crayons-field__required crayons-hover-tooltip" data-tooltip="* - required">*</span>
|
||||
<% end %>
|
||||
<%= f.text_field :username, class: "crayons-textfield", required: true %>
|
||||
</div>
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
<div class="crayons-field mt-2">
|
||||
<%= f.label :email, class: "crayons-field__label", aria: { label: "Email" } do %>
|
||||
Email
|
||||
<span class="crayons-field__required crayons-tooltip" data-tooltip="* - required">*</span>
|
||||
<span class="crayons-field__required crayons-hover-tooltip" data-tooltip="* - required">*</span>
|
||||
<% end %>
|
||||
<%= f.email_field :email, autocomplete: "email", class: "crayons-textfield", required: true %>
|
||||
</div>
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
<div class="crayons-field mt-2">
|
||||
<%= f.label :password, class: "crayons-field__label", aria: { label: "Password" } do %>
|
||||
Password
|
||||
<span class="crayons-field__required crayons-tooltip" data-tooltip="* - required">*</span>
|
||||
<span class="crayons-field__required crayons-hover-tooltip" data-tooltip="* - required">*</span>
|
||||
<% end %>
|
||||
<%= f.password_field :password, autocomplete: "current-password", class: "crayons-textfield", required: true %>
|
||||
</div>
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
<div class="crayons-field mt-2">
|
||||
<%= f.label :password_confirmation, class: "crayons-field__label", aria: { label: "Password Confirmation" } do %>
|
||||
Password Confirmation
|
||||
<span class="crayons-field__required crayons-tooltip" data-tooltip="* - required">*</span>
|
||||
<span class="crayons-field__required crayons-hover-tooltip" data-tooltip="* - required">*</span>
|
||||
<% end %>
|
||||
<%= f.password_field :password_confirmation, autocomplete: "current-password", class: "crayons-textfield", required: true %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -81,8 +81,8 @@ RSpec.describe AuthenticationHelper, type: :helper do
|
|||
allow(Settings::Authentication).to receive(:allow_email_password_registration).and_return(false)
|
||||
end
|
||||
|
||||
it "returns 'crayons-tooltip' class for relevant helpers" do
|
||||
expect(tooltip_class_on_auth_provider_enablebtn).to eq("crayons-tooltip")
|
||||
it "returns 'crayons-hover-tooltip' class for relevant helpers" do
|
||||
expect(tooltip_class_on_auth_provider_enablebtn).to eq("crayons-hover-tooltip")
|
||||
end
|
||||
|
||||
it "returns 'disabled' attribute for relevant helper" do
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue