This commit is contained in:
Kim Anh Nguyen 2026-02-04 03:03:23 +02:00 committed by GitHub
commit e320856bee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 254 additions and 72 deletions

View file

@ -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>
);
})}

View file

@ -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];

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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');
}