diff --git a/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.test.jsx b/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.test.jsx index ee907be96f..90ac1c634b 100644 --- a/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.test.jsx +++ b/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.test.jsx @@ -1603,6 +1603,7 @@ describe('components/AdvancedCreateComment', () => { (instance) => instance.find(AdvanceTextEditor), (instance) => instance.state().draft.message, false, + 'reply_textbox', ); it('should blur when ESCAPE is pressed', () => { diff --git a/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx b/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx index 63c2a57bd5..6db290a676 100644 --- a/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx +++ b/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx @@ -7,6 +7,8 @@ import React from 'react'; import {ModalData} from 'types/actions.js'; +import {isNil} from 'lodash'; + import {sortFileInfos} from 'mattermost-redux/utils/file_utils'; import * as GlobalActions from 'actions/global_actions'; @@ -25,7 +27,7 @@ import { groupsMentionedInText, mentionsMinusSpecialMentionsInText, } from 'utils/post_utils'; -import {getTable, hasHtmlLink, formatMarkdownMessage, isGitHubCodeBlock, formatGithubCodePaste} from 'utils/paste'; +import {getTable, hasHtmlLink, formatMarkdownMessage, isGitHubCodeBlock, formatGithubCodePaste, isHttpProtocol, isHttpsProtocol} from 'utils/paste'; import NotifyConfirmModal from 'components/notify_confirm_modal'; import {FileUpload as FileUploadClass} from 'components/file_upload/file_upload'; @@ -40,6 +42,8 @@ import {ServerError} from '@mattermost/types/errors'; import {FileInfo} from '@mattermost/types/files'; import EmojiMap from 'utils/emoji_map'; import { + applyLinkMarkdown, + ApplyLinkMarkdownOptions, applyMarkdown, ApplyMarkdownOptions, } from 'utils/markdown/apply_markdown'; @@ -425,15 +429,37 @@ class AdvancedCreateComment extends React.PureComponent { } const {clipboardData} = e; + + const target = e.target as TextboxElement; + + const {selectionStart, selectionEnd, value} = target; + + const hasSelection = !isNil(selectionStart) && !isNil(selectionEnd) && selectionStart < selectionEnd; + const clipboardText = clipboardData.getData('text/plain'); + const isClipboardTextURL = isHttpProtocol(clipboardText) || isHttpsProtocol(clipboardText); + const shouldApplyLinkMarkdown = hasSelection && isClipboardTextURL; + const hasLinks = hasHtmlLink(clipboardData); let table = getTable(clipboardData); - if (!table && !hasLinks) { + if (!table && !hasLinks && !shouldApplyLinkMarkdown) { return; } - table = table as HTMLTableElement; e.preventDefault(); + if (shouldApplyLinkMarkdown) { + this.applyLinkMarkdownWhenPaste({ + selectionStart, + selectionEnd, + message: value, + url: clipboardText, + }); + + return; + } + + table = table as HTMLTableElement; + const draft = this.state.draft!; let message = draft.message; @@ -912,6 +938,15 @@ class AdvancedCreateComment extends React.PureComponent { selectionEnd, message: value, }); + } else if (Utils.isTextSelectedInPostOrReply(e) && Keyboard.isKeyPressed(e, KeyCodes.K)) { + e.stopPropagation(); + e.preventDefault(); + this.applyMarkdown({ + markdownMode: 'link', + selectionStart, + selectionEnd, + message: value, + }); } } else if (ctrlAltCombo) { if (Keyboard.isKeyPressed(e, KeyCodes.K)) { @@ -1010,6 +1045,25 @@ class AdvancedCreateComment extends React.PureComponent { }); }; + applyLinkMarkdownWhenPaste = (params: ApplyLinkMarkdownOptions) => { + const res = applyLinkMarkdown(params); + + const draft = this.state.draft!; + const modifiedDraft = { + ...draft, + message: res.message, + }; + + this.handleDraftChange(modifiedDraft); + + this.setState({ + draft: modifiedDraft, + }, () => { + const textbox = this.textboxRef.current?.getInputBox(); + Utils.setSelectionRange(textbox, res.selectionEnd + 1, res.selectionEnd + 1); + }); + }; + handleFileUploadChange = () => { this.isDraftEdited = true; this.focusTextbox(); diff --git a/webapp/channels/src/components/advanced_create_post/advanced_create_post.test.jsx b/webapp/channels/src/components/advanced_create_post/advanced_create_post.test.jsx index eb04b8a1d2..5c1e824772 100644 --- a/webapp/channels/src/components/advanced_create_post/advanced_create_post.test.jsx +++ b/webapp/channels/src/components/advanced_create_post/advanced_create_post.test.jsx @@ -1581,6 +1581,7 @@ describe('components/advanced_create_post', () => { (instance) => instance.find(AdvanceTextEditor), (instance) => instance.state().message, false, + 'post_textbox', ); /** diff --git a/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx b/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx index af9cc8a6b0..29f04579ff 100644 --- a/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx +++ b/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx @@ -9,6 +9,8 @@ import classNames from 'classnames'; import {AlertCircleOutlineIcon, CheckCircleOutlineIcon} from '@mattermost/compass-icons/components'; +import {isNil} from 'lodash'; + import {Posts} from 'mattermost-redux/constants'; import {sortFileInfos} from 'mattermost-redux/utils/file_utils'; import {ActionResult} from 'mattermost-redux/types/actions'; @@ -33,11 +35,11 @@ import { groupsMentionedInText, mentionsMinusSpecialMentionsInText, } from 'utils/post_utils'; -import {getTable, hasHtmlLink, formatMarkdownMessage, formatGithubCodePaste, isGitHubCodeBlock} from 'utils/paste'; +import {getTable, hasHtmlLink, formatMarkdownMessage, formatGithubCodePaste, isGitHubCodeBlock, isHttpProtocol, isHttpsProtocol} from 'utils/paste'; import * as UserAgent from 'utils/user_agent'; import * as Utils from 'utils/utils'; import EmojiMap from 'utils/emoji_map'; -import {applyMarkdown, ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; +import {applyLinkMarkdown, ApplyLinkMarkdownOptions, applyMarkdown, ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; import Tooltip from 'components/tooltip'; import OverlayTrigger from 'components/overlay_trigger'; @@ -911,15 +913,37 @@ class AdvancedCreatePost extends React.PureComponent { const {clipboardData} = e; + const target = e.target as TextboxElement; + + const {selectionStart, selectionEnd, value} = target; + + const hasSelection = !isNil(selectionStart) && !isNil(selectionEnd) && selectionStart < selectionEnd; + const clipboardText = clipboardData.getData('text/plain'); + const isClipboardTextURL = isHttpProtocol(clipboardText) || isHttpsProtocol(clipboardText); + const shouldApplyLinkMarkdown = hasSelection && isClipboardTextURL; + const hasLinks = hasHtmlLink(clipboardData); let table = getTable(clipboardData); - if (!table && !hasLinks) { + + if (!table && !hasLinks && !shouldApplyLinkMarkdown) { return; } - table = table as HTMLTableElement; e.preventDefault(); + if (shouldApplyLinkMarkdown) { + this.applyLinkMarkdownWhenPaste({ + selectionStart, + selectionEnd, + message: value, + url: clipboardText, + }); + + return; + } + + table = table as HTMLTableElement; + const message = this.state.message; if (table && isGitHubCodeBlock(table.className)) { const selectionStart = (e.target as any).selectionStart; @@ -1210,6 +1234,15 @@ class AdvancedCreatePost extends React.PureComponent { selectionEnd, message: value, }); + } else if (Utils.isTextSelectedInPostOrReply(e) && Keyboard.isKeyPressed(e, KeyCodes.K)) { + e.stopPropagation(); + e.preventDefault(); + this.applyMarkdown({ + markdownMode: 'link', + selectionStart, + selectionEnd, + message: value, + }); } } else if (ctrlAltCombo) { if (Keyboard.isKeyPressed(e, KeyCodes.K)) { @@ -1362,6 +1395,24 @@ class AdvancedCreatePost extends React.PureComponent { }); }; + applyLinkMarkdownWhenPaste = (params: ApplyLinkMarkdownOptions) => { + const res = applyLinkMarkdown(params); + + this.setState({ + message: res.message, + }, () => { + const textbox = this.textboxRef.current?.getInputBox(); + Utils.setSelectionRange(textbox, res.selectionEnd + 1, res.selectionEnd + 1); + + const draft = { + ...this.props.draft, + message: this.state.message, + }; + + this.handleDraftChange(draft); + }); + }; + reactToLastMessage = (e: KeyboardEvent) => { e.preventDefault(); diff --git a/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx b/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx index cc5b5f8d97..b6c2b9316d 100644 --- a/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx +++ b/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx @@ -80,7 +80,7 @@ export default class ChannelNavigator extends React.PureComponent { handleQuickSwitchKeyPress = (e: KeyboardEvent) => { if (Keyboard.cmdOrCtrlPressed(e) && !e.shiftKey && Keyboard.isKeyPressed(e, Constants.KeyCodes.K)) { - if (!e.altKey) { + if (!e.altKey && !Utils.isTextSelectedInPostOrReply(e)) { e.preventDefault(); this.toggleQuickSwitchModal(); } diff --git a/webapp/channels/src/tests/helpers/markdown_hotkey_helpers.js b/webapp/channels/src/tests/helpers/markdown_hotkey_helpers.js index 79f92dbf6a..b4f88a3e32 100644 --- a/webapp/channels/src/tests/helpers/markdown_hotkey_helpers.js +++ b/webapp/channels/src/tests/helpers/markdown_hotkey_helpers.js @@ -30,7 +30,7 @@ export function makeSelectionEvent(input, start, end) { }; } -function makeMarkdownHotkeyEvent(input, start, end, keycode, altKey = false) { +function makeMarkdownHotkeyEvent(input, start, end, keycode, altKey = false, targetId = 'root') { return { preventDefault: jest.fn(), stopPropagation: jest.fn(), @@ -42,6 +42,7 @@ function makeMarkdownHotkeyEvent(input, start, end, keycode, altKey = false) { selectionStart: start, selectionEnd: end, value: input, + id: targetId, }, }; } @@ -66,6 +67,10 @@ export function makeItalicHotkeyEvent(input, start, end) { return makeMarkdownHotkeyEvent(input, start, end, Constants.KeyCodes.I); } +function makeLinkHotKeyWithoutAltKeyEvent(input, start, end, targetId) { + return makeMarkdownHotkeyEvent(input, start, end, Constants.KeyCodes.K, false, targetId); +} + function makeLinkHotKeyEvent(input, start, end) { return makeMarkdownHotkeyEvent(input, start, end, Constants.KeyCodes.K, true); } @@ -77,7 +82,7 @@ function makeLinkHotKeyEvent(input, start, end) { * @param {function} getValue - single parameter for the React Component instance * NOTE: runs Jest tests */ -export function testComponentForMarkdownHotkeys(generateInstance, initRefs, find, getValue, intlInjected = true) { +export function testComponentForMarkdownHotkeys(generateInstance, initRefs, find, getValue, intlInjected = true, targetId) { const shallowRender = intlInjected ? shallowWithIntl : shallow; test('component adds bold markdown', () => { // "Fafda" is selected with ctrl + B hotkey @@ -143,6 +148,29 @@ export function testComponentForMarkdownHotkeys(generateInstance, initRefs, find expect(setSelectionRange).toHaveBeenCalled(); }); + test('component adds link markdown with hitting Ctrl + K when something is selected and event target has specific targetId', () => { + // "Fafda" is selected with ctrl + K hotkey + const input = 'Jalebi Fafda & Sambharo'; + const e = makeLinkHotKeyWithoutAltKeyEvent(input, 7, 12, targetId); + + const instance = shallowRender(generateInstance(input)); + + let selectionStart = -1; + let selectionEnd = -1; + const setSelectionRange = jest.fn((start, end) => { + selectionStart = start; + selectionEnd = end; + }); + initRefs(instance, setSelectionRange); + + find(instance).props().onKeyDown?.(e); + find(instance).props().handleKeyDown?.(e); + expect(getValue(instance)).toBe('Jalebi [Fafda](url) & Sambharo'); + expect(setSelectionRange).toHaveBeenCalled(); + expect(selectionStart).toBe(15); + expect(selectionEnd).toBe(18); + }); + test('component adds link markdown when something is selected', () => { // "Fafda" is selected with ctrl + alt + K hotkey const input = 'Jalebi Fafda & Sambharo'; diff --git a/webapp/channels/src/utils/markdown/apply_markdown.ts b/webapp/channels/src/utils/markdown/apply_markdown.ts index 06484cc61c..8139fa270c 100644 --- a/webapp/channels/src/utils/markdown/apply_markdown.ts +++ b/webapp/channels/src/utils/markdown/apply_markdown.ts @@ -20,6 +20,11 @@ type ApplySpecificMarkdownOptions = ApplyMarkdownReturnValue & { delimiter?: string; } +export type ApplyLinkMarkdownOptions = ApplySpecificMarkdownOptions & { + url?: string; + +} + export function applyMarkdown(options: ApplyMarkdownOptions): ApplyMarkdownReturnValue { const {selectionEnd, selectionStart, message, markdownMode} = options; @@ -396,14 +401,14 @@ function applyBoldItalicMarkdown({selectionEnd, selectionStart, message, markdow }; } -function applyLinkMarkdown({selectionEnd, selectionStart, message}: ApplySpecificMarkdownOptions) { +export function applyLinkMarkdown({selectionEnd, selectionStart, message, url = 'url'}: ApplyLinkMarkdownOptions) { // const prefix = message.slice(0, selectionStart); const selection = message.slice(selectionStart, selectionEnd); const suffix = message.slice(selectionEnd); const delimiterStart = '['; - const delimiterEnd = '](url)'; + const delimiterEnd = `](${url})`; // Does the selection have link markdown? const hasMarkdown = prefix.endsWith(delimiterStart) && suffix.startsWith(delimiterEnd); @@ -431,7 +436,7 @@ function applyLinkMarkdown({selectionEnd, selectionStart, message}: ApplySpecifi // there is something selected; put markdown around it and preserve selection newValue = prefix + delimiterStart + selection + delimiterEnd + suffix; newStart = selectionEnd + urlShift; - newEnd = newStart + urlShift; + newEnd = newStart + url.length; } else { // nothing is selected const spaceBefore = prefix.charAt(prefix.length - 1) === ' '; diff --git a/webapp/channels/src/utils/paste.tsx b/webapp/channels/src/utils/paste.tsx index 53af82b7f7..bfbc76bea1 100644 --- a/webapp/channels/src/utils/paste.tsx +++ b/webapp/channels/src/utils/paste.tsx @@ -55,6 +55,14 @@ export function isGitHubCodeBlock(tableClassName: string): boolean { return result; } +export function isHttpProtocol(url: string): boolean { + return url.startsWith('http://'); +} + +export function isHttpsProtocol(url: string): boolean { + return url.startsWith('https://'); +} + function isHeaderlessTable(table: HTMLTableElement): boolean { return table.querySelectorAll('th').length === 0; } @@ -85,9 +93,7 @@ export function formatMarkdownMessage(clipboardData: DataTransfer, message?: str export function formatGithubCodePaste({message, clipboardData, selectionStart, selectionEnd}: FormatCodeOptions): {formattedMessage: string; formattedCodeBlock: string} { const textSelected = selectionStart !== selectionEnd; - const {firstPiece, lastPiece} = textSelected ? - splitMessageBasedOnTextSelection(selectionStart ?? message.length, selectionEnd ?? message.length, message) : - splitMessageBasedOnCaretPosition(selectionStart ?? message.length, message); + const {firstPiece, lastPiece} = textSelected ? splitMessageBasedOnTextSelection(selectionStart ?? message.length, selectionEnd ?? message.length, message) : splitMessageBasedOnCaretPosition(selectionStart ?? message.length, message); // Add new lines if content exists before or after the cursor. const requireStartLF = firstPiece === '' ? '' : '\n'; diff --git a/webapp/channels/src/utils/utils.tsx b/webapp/channels/src/utils/utils.tsx index cb806c7ac0..3d5ae404a7 100644 --- a/webapp/channels/src/utils/utils.tsx +++ b/webapp/channels/src/utils/utils.tsx @@ -12,6 +12,8 @@ import type {Locale} from 'date-fns'; import {getName} from 'country-list'; +import {isNil} from 'lodash'; + import Constants, {FileTypes, ValidationErrors, A11yCustomEventTypes, A11yFocusEventDetail} from 'utils/constants'; import { @@ -1661,6 +1663,26 @@ function isSelection() { return selection!.type === 'Range'; } +export function isTextSelectedInPostOrReply(e: React.KeyboardEvent | KeyboardEvent) { + const {id} = e.target as HTMLElement; + + const isTypingInPost = id === 'post_textbox'; + const isTypingInReply = id === 'reply_textbox'; + + if (!isTypingInPost && !isTypingInReply) { + return false; + } + + const { + selectionStart, + selectionEnd, + } = e.target as TextboxElement; + + const hasSelection = !isNil(selectionStart) && !isNil(selectionEnd) && selectionStart < selectionEnd; + + return hasSelection; +} + /* * Returns false when the element clicked or its ancestors * is a potential click target (link, button, image, etc..)