mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-03 20:40:00 -05:00
Merge 4846a73b12 into 0263262ef4
This commit is contained in:
commit
e320856bee
5 changed files with 254 additions and 72 deletions
|
|
@ -16,6 +16,7 @@ import type {ApplyMarkdownOptions, MarkdownMode} from 'utils/markdown/apply_mark
|
|||
|
||||
import FormattingIcon, {IconContainer} from './formatting_icon';
|
||||
import {useFormattingBarControls} from './hooks';
|
||||
import Link from './link';
|
||||
|
||||
export const Separator = styled.div`
|
||||
display: block;
|
||||
|
|
@ -146,6 +147,8 @@ const FormattingBar = (props: FormattingBarProps): JSX.Element => {
|
|||
const [showHiddenControls, setShowHiddenControls] = useState(false);
|
||||
const formattingBarRef = useRef<HTMLDivElement>(null);
|
||||
const {controls, hiddenControls, wideMode} = useFormattingBarControls(formattingBarRef);
|
||||
const [isLinkModalOpen, setIsLinkModalOpen] = useState(false);
|
||||
const [selection, setSelection] = useState<{start?: number; end?: number; text: string}>({text: ''});
|
||||
|
||||
const {formatMessage} = useIntl();
|
||||
const HiddenControlsButtonAriaLabel = formatMessage({id: 'accessibility.button.hidden_controls_button', defaultMessage: 'show hidden formatting options'});
|
||||
|
|
@ -187,7 +190,7 @@ const FormattingBar = (props: FormattingBarProps): JSX.Element => {
|
|||
// get the current selection values and return early (doing nothing) when we don't get valid values
|
||||
const {start, end} = getCurrentSelection();
|
||||
|
||||
if (start === null || end === null) {
|
||||
if (start === null || start === undefined || end === null || end === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -216,6 +219,34 @@ const FormattingBar = (props: FormattingBarProps): JSX.Element => {
|
|||
|
||||
const showSeparators = wideMode === 'wide';
|
||||
|
||||
const handleLinkModalToggle = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
const {start, end} = getCurrentSelection();
|
||||
const message = getCurrentMessage();
|
||||
const text = (start !== undefined && end !== undefined) ? message.substring(start, end) : '';
|
||||
|
||||
setSelection({start, end, text});
|
||||
}
|
||||
setIsLinkModalOpen(isOpen);
|
||||
};
|
||||
|
||||
const handleLinkApply = ({text, link}: {text: string; link: string}) => {
|
||||
const {start, end} = selection;
|
||||
|
||||
if (start === undefined || end === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyMarkdown({
|
||||
markdownMode: 'link',
|
||||
selectionStart: start,
|
||||
selectionEnd: end,
|
||||
message: getCurrentMessage(),
|
||||
url: link,
|
||||
text,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FormattingBarContainer
|
||||
ref={formattingBarRef}
|
||||
|
|
@ -230,7 +261,17 @@ const FormattingBar = (props: FormattingBarProps): JSX.Element => {
|
|||
onClick={makeFormattingHandler(mode)}
|
||||
disabled={disableControls}
|
||||
/>
|
||||
{mode === 'heading' && showSeparators && <Separator/>}
|
||||
{mode === 'heading' && (
|
||||
<>
|
||||
{showSeparators && <Separator/>}
|
||||
<Link
|
||||
onToggle={handleLinkModalToggle}
|
||||
isMenuOpen={isLinkModalOpen}
|
||||
selectionText={selection.text}
|
||||
onApply={handleLinkApply}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ const MAP_WIDE_MODE_TO_CONTROLS_QUANTITY: {[key in WideMode]: number} = {
|
|||
};
|
||||
|
||||
export function splitFormattingBarControls(wideMode: WideMode) {
|
||||
const allControls: MarkdownMode[] = ['bold', 'italic', 'strike', 'heading', 'link', 'code', 'quote', 'ul', 'ol'];
|
||||
const allControls: MarkdownMode[] = ['bold', 'italic', 'strike', 'heading', 'code', 'quote', 'ul', 'ol'];
|
||||
|
||||
const controlsLength = MAP_WIDE_MODE_TO_CONTROLS_QUANTITY[wideMode];
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
@use 'utils/mixins';
|
||||
|
||||
.LinkModal {
|
||||
&__apply {
|
||||
@include mixins.button-small;
|
||||
@include mixins.primary-button;
|
||||
}
|
||||
|
||||
&__cancel {
|
||||
@include mixins.button-small;
|
||||
@include mixins.tertiary-button;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {useSelector} from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import CompassDesignProvider from 'components/compass_design_provider';
|
||||
import * as Menu from 'components/menu';
|
||||
|
||||
import Constants from 'utils/constants';
|
||||
import * as Keyboard from 'utils/keyboard';
|
||||
|
||||
import FormattingIcon from './formatting_icon';
|
||||
|
||||
import {Footer, Header} from '../../post_priority/post_priority_picker_item';
|
||||
|
||||
import './link.scss';
|
||||
|
||||
export const LinkModal = styled.div`
|
||||
width: 324px;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;`;
|
||||
|
||||
export const LinkModalRow = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;`;
|
||||
|
||||
type Props = {
|
||||
onApply: (props: { text: string; link: string }) => void;
|
||||
onToggle: (isOpen: boolean) => void;
|
||||
isMenuOpen: boolean;
|
||||
selectionText: string;
|
||||
}
|
||||
|
||||
function Link({
|
||||
onApply,
|
||||
onToggle,
|
||||
isMenuOpen,
|
||||
selectionText,
|
||||
}: Props) {
|
||||
const {formatMessage} = useIntl();
|
||||
const [text, setText] = useState('');
|
||||
const [link, setLink] = useState('https://');
|
||||
|
||||
const theme = useSelector(getTheme);
|
||||
const tooltipText = formatMessage({id: 'accessibility.button.link', defaultMessage: 'link'});
|
||||
|
||||
useEffect(() => {
|
||||
if (isMenuOpen) {
|
||||
setText(selectionText);
|
||||
setLink('https://');
|
||||
}
|
||||
}, [isMenuOpen, selectionText]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onToggle(false);
|
||||
}, [onToggle]);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
onApply({
|
||||
text, link,
|
||||
});
|
||||
handleClose();
|
||||
}, [onApply, handleClose, text, link]);
|
||||
|
||||
const handleFooterButtonAction = useCallback((e: React.KeyboardEvent<HTMLButtonElement>, actionFn: () => void) => {
|
||||
if (Keyboard.isKeyPressed(e, Constants.KeyCodes.ENTER)) {
|
||||
e.preventDefault();
|
||||
actionFn();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const footer = useMemo(() =>
|
||||
(<div>
|
||||
<Footer key='footer'>
|
||||
<button
|
||||
type='submit'
|
||||
className='LinkModal__cancel'
|
||||
onClick={handleClose}
|
||||
onKeyDown={(e) => handleFooterButtonAction(e, handleClose)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='post_priority.picker.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type='submit'
|
||||
className='LinkModal__apply'
|
||||
onClick={handleApply}
|
||||
onKeyDown={(e) => handleFooterButtonAction(e, handleApply)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='post_priority.picker.apply'
|
||||
defaultMessage='Apply'
|
||||
/>
|
||||
</button>
|
||||
</Footer>
|
||||
</div>), [handleApply, handleClose, handleFooterButtonAction]);
|
||||
|
||||
return (<CompassDesignProvider theme={theme}>
|
||||
<Menu.Container
|
||||
menuButton={{
|
||||
id: 'messagePriority',
|
||||
as: 'div',
|
||||
children: (
|
||||
<FormattingIcon
|
||||
mode='link'
|
||||
className='control'
|
||||
disabled={false}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
menu={{
|
||||
id: 'post.priority.dropdown',
|
||||
width: 'max-content',
|
||||
onToggle,
|
||||
isMenuOpen,
|
||||
}}
|
||||
menuButtonTooltip={{
|
||||
text: tooltipText,
|
||||
}}
|
||||
|
||||
menuHeader={
|
||||
<div>
|
||||
<Header className='modal-title'>
|
||||
{formatMessage({
|
||||
id: 'link.modal.header',
|
||||
defaultMessage: 'Add link',
|
||||
})}
|
||||
</Header>
|
||||
<Menu.Separator/>
|
||||
</div>
|
||||
}
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
|
||||
menuFooter={footer}
|
||||
closeMenuOnTab={false}
|
||||
>
|
||||
<LinkModal>
|
||||
<LinkModalRow>
|
||||
{/* eslint-disable-next-line react/jsx-no-literals */}
|
||||
<label htmlFor='text-input-id'>Text</label>
|
||||
<input
|
||||
id='text-input-id'
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
</LinkModalRow>
|
||||
<LinkModalRow>
|
||||
{/* eslint-disable-next-line react/jsx-no-literals */}
|
||||
<label htmlFor='link-input-id'>Link</label>
|
||||
<input
|
||||
id='link-input-id'
|
||||
value={link}
|
||||
onChange={(e) => {
|
||||
setLink(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
</LinkModalRow>
|
||||
</LinkModal>
|
||||
<div/>
|
||||
</Menu.Container>
|
||||
</CompassDesignProvider>);
|
||||
}
|
||||
|
||||
export default Link;
|
||||
|
|
@ -8,6 +8,8 @@ export type ApplyMarkdownOptions = {
|
|||
selectionStart: number | null;
|
||||
selectionEnd: number | null;
|
||||
message: string;
|
||||
url?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
type ApplyMarkdownReturnValue = {
|
||||
|
|
@ -24,11 +26,11 @@ type ApplySpecificMarkdownOptions = ApplyMarkdownReturnValue & {
|
|||
|
||||
export type ApplyLinkMarkdownOptions = ApplySpecificMarkdownOptions & {
|
||||
url?: string;
|
||||
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export function applyMarkdown(options: ApplyMarkdownOptions): ApplyMarkdownReturnValue {
|
||||
const {selectionEnd, selectionStart, message, markdownMode} = options;
|
||||
const {selectionEnd, selectionStart, message, markdownMode, url, text} = options;
|
||||
|
||||
if (selectionStart === null || selectionEnd === null) {
|
||||
/**
|
||||
|
|
@ -59,7 +61,7 @@ export function applyMarkdown(options: ApplyMarkdownOptions): ApplyMarkdownRetur
|
|||
case 'italic':
|
||||
return applyItalicMarkdown({selectionEnd, selectionStart, message});
|
||||
case 'link':
|
||||
return applyLinkMarkdown({selectionEnd, selectionStart, message});
|
||||
return applyLinkMarkdown({selectionEnd, selectionStart, message, url, text});
|
||||
case 'ol':
|
||||
return applyOlMarkdown({selectionEnd, selectionStart, message});
|
||||
case 'ul':
|
||||
|
|
@ -408,10 +410,10 @@ function applyBoldItalicMarkdown({selectionEnd, selectionStart, message, markdow
|
|||
|
||||
export const DEFAULT_PLACEHOLDER_URL = 'url';
|
||||
|
||||
export function applyLinkMarkdown({selectionEnd, selectionStart, message, url = DEFAULT_PLACEHOLDER_URL}: ApplyLinkMarkdownOptions) {
|
||||
export function applyLinkMarkdown({selectionEnd, selectionStart, message, url = DEFAULT_PLACEHOLDER_URL, text}: ApplyLinkMarkdownOptions) {
|
||||
// <prefix> <selection> <suffix>
|
||||
const prefix = message.slice(0, selectionStart);
|
||||
const selection = message.slice(selectionStart, selectionEnd);
|
||||
const selection = text;
|
||||
const suffix = message.slice(selectionEnd);
|
||||
|
||||
const delimiterStart = '[';
|
||||
|
|
@ -424,8 +426,6 @@ export function applyLinkMarkdown({selectionEnd, selectionStart, message, url =
|
|||
let newStart: number;
|
||||
let newEnd: number;
|
||||
|
||||
// When url is to be selected in [...](url), selection cursors need to shift by this much.
|
||||
const urlShift = delimiterStart.length + 2; // ']'.length + ']('.length
|
||||
if (hasMarkdown) {
|
||||
// message already has the markdown; remove it
|
||||
newValue =
|
||||
|
|
@ -434,60 +434,15 @@ export function applyLinkMarkdown({selectionEnd, selectionStart, message, url =
|
|||
suffix.slice(delimiterEnd.length);
|
||||
newStart = selectionStart - delimiterStart.length;
|
||||
newEnd = selectionEnd - delimiterStart.length;
|
||||
} else if (message.length === 0) {
|
||||
// no input; Add [|](url)
|
||||
newValue = delimiterStart + delimiterEnd;
|
||||
newStart = delimiterStart.length;
|
||||
newEnd = delimiterStart.length;
|
||||
} else if (selectionStart < selectionEnd) {
|
||||
// there is something selected; put markdown around it and preserve selection
|
||||
newValue = prefix + delimiterStart + selection + delimiterEnd + suffix;
|
||||
newStart = selectionEnd + urlShift;
|
||||
newEnd = newStart + url.length;
|
||||
} else {
|
||||
// nothing is selected
|
||||
const spaceBefore = prefix.charAt(prefix.length - 1) === ' ';
|
||||
const spaceAfter = suffix.charAt(0) === ' ';
|
||||
const cursorBeforeWord =
|
||||
(selectionStart !== 0 && spaceBefore && !spaceAfter) || (selectionStart === 0 && !spaceAfter);
|
||||
const cursorAfterWord =
|
||||
(selectionEnd !== message.length && spaceAfter && !spaceBefore) ||
|
||||
(selectionEnd === message.length && !spaceBefore);
|
||||
// This is the logic for *adding* a link.
|
||||
// It's now much simpler.
|
||||
// Just replace the original selection with the new markdown.
|
||||
newValue = prefix + delimiterStart + selection + delimiterEnd + suffix;
|
||||
|
||||
if (cursorBeforeWord) {
|
||||
// cursor before a word
|
||||
const word = message.slice(selectionStart, findWordEnd(message, selectionStart));
|
||||
|
||||
newValue = prefix + delimiterStart + word + delimiterEnd + suffix.slice(word.length);
|
||||
newStart = selectionStart + word.length + urlShift;
|
||||
newEnd = newStart + urlShift;
|
||||
} else if (cursorAfterWord) {
|
||||
// cursor after a word
|
||||
const cursorAtEndOfLine = selectionStart === selectionEnd && selectionEnd === message.length;
|
||||
if (cursorAtEndOfLine) {
|
||||
// cursor at end of line
|
||||
newValue = message + ' ' + delimiterStart + delimiterEnd;
|
||||
newStart = selectionEnd + 1 + delimiterStart.length;
|
||||
newEnd = newStart;
|
||||
} else {
|
||||
// cursor not at end of line
|
||||
const word = message.slice(findWordStart(message, selectionStart), selectionStart);
|
||||
|
||||
newValue =
|
||||
prefix.slice(0, prefix.length - word.length) + delimiterStart + word + delimiterEnd + suffix;
|
||||
newStart = selectionStart + urlShift;
|
||||
newEnd = newStart + urlShift;
|
||||
}
|
||||
} else {
|
||||
// cursor is in between a word
|
||||
const wordStart = findWordStart(message, selectionStart);
|
||||
const wordEnd = findWordEnd(message, selectionStart);
|
||||
const word = message.slice(wordStart, wordEnd);
|
||||
|
||||
newValue = prefix.slice(0, wordStart) + delimiterStart + word + delimiterEnd + message.slice(wordEnd);
|
||||
newStart = wordEnd + urlShift;
|
||||
newEnd = newStart + urlShift;
|
||||
}
|
||||
// Set the cursor position to the end of the newly inserted link
|
||||
newStart = selectionStart + (delimiterStart + selection + delimiterEnd).length;
|
||||
newEnd = newStart;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -504,16 +459,6 @@ function applyCodeMarkdown({selectionEnd, selectionStart, message}: ApplySpecifi
|
|||
return applyMarkdownToSelection({selectionEnd, selectionStart, message, delimiter: '`'});
|
||||
}
|
||||
|
||||
function findWordEnd(text: string, start: number) {
|
||||
const wordEnd = text.indexOf(' ', start);
|
||||
return wordEnd === -1 ? text.length : wordEnd;
|
||||
}
|
||||
|
||||
function findWordStart(text: string, start: number) {
|
||||
const wordStart = text.lastIndexOf(' ', start - 1) + 1;
|
||||
return wordStart === -1 ? 0 : wordStart;
|
||||
}
|
||||
|
||||
function isSelectionMultiline(message: string, selectionStart: number, selectionEnd: number) {
|
||||
return message.slice(selectionStart, selectionEnd).includes('\n');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue