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 7fd923328d..90f10d1f67 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 @@ -8,9 +8,14 @@ import {testComponentForLineBreak} from 'tests/helpers/line_break_helpers'; import {testComponentForMarkdownHotkeys} from 'tests/helpers/markdown_hotkey_helpers.js'; import Constants, {ModalIdentifiers} from 'utils/constants'; +import {execCommandInsertText} from 'utils/exec_commands'; import AdvancedCreateComment from 'components/advanced_create_comment/advanced_create_comment'; -import AdvanceTextEditor from '../advanced_text_editor/advanced_text_editor'; +import AdvanceTextEditor from 'components/advanced_text_editor/advanced_text_editor'; + +jest.mock('utils/exec_commands', () => ({ + execCommandInsertText: jest.fn(), +})); describe('components/AdvancedCreateComment', () => { jest.useFakeTimers(); @@ -1314,7 +1319,7 @@ describe('components/AdvancedCreateComment', () => { expect(scrollToBottom).toBeCalledTimes(2); }); - it('should be able to format a pasted markdown table', () => { + test('should be able to format a pasted markdown table', () => { const draft = emptyDraft; const wrapper = shallow( { const markdownTable = '| test | test |\n| --- | --- |\n| test | test |'; wrapper.instance().pasteHandler(event); - expect(wrapper.state('draft').message).toBe(markdownTable); + expect(execCommandInsertText).toHaveBeenCalledWith(markdownTable); }); - it('should be able to format a pasted markdown table without headers', () => { + test('should be able to format a pasted markdown table without headers', () => { const draft = emptyDraft; const wrapper = shallow( { const markdownTable = '| test | test |\n| --- | --- |\n| test | test |\n'; wrapper.instance().pasteHandler(event); - expect(wrapper.state('draft').message).toBe(markdownTable); + expect(execCommandInsertText).toHaveBeenCalledWith(markdownTable); }); - it('should be able to format a pasted hyperlink', () => { + test('should be able to format a pasted hyperlink', () => { const draft = emptyDraft; const wrapper = shallow( { const markdownLink = '[link text](https://test.domain)'; wrapper.instance().pasteHandler(event); - expect(wrapper.state('draft').message).toBe(markdownLink); + expect(execCommandInsertText).toHaveBeenCalledWith(markdownLink); }); - it('should be able to format a github codeblock (pasted as a table)', () => { + test('should be able to format a github codeblock (pasted as a table)', () => { const draft = emptyDraft; const wrapper = shallow( { const codeBlockMarkdown = "```\n// a javascript codeblock example\nif (1 > 0) {\n return 'condition is true';\n}\n```"; wrapper.instance().pasteHandler(event); - expect(wrapper.state('draft').message).toBe(codeBlockMarkdown); - }); - - it('should be able to format a github codeblock (pasted as a table) with with existing draft post', () => { - const draft = emptyDraft; - const wrapper = shallow( - , - ); - - const mockTop = () => { - return document.createElement('div'); - }; - - const mockImpl = () => { - return { - setSelectionRange: jest.fn(), - getBoundingClientRect: jest.fn(mockTop), - focus: jest.fn(), - }; - }; - - wrapper.instance().textboxRef.current = {getInputBox: jest.fn(mockImpl), getBoundingClientRect: jest.fn(), focus: jest.fn()}; - wrapper.setState({ - draft: { - ...draft, - message: 'test', - }, - caretPosition: 'test'.length, // cursor is at the end - }); - - const event = { - target: { - id: 'reply_textbox', - }, - preventDefault: jest.fn(), - clipboardData: { - items: [1], - types: ['text/plain', 'text/html'], - getData: (type) => { - if (type === 'text/plain') { - return '// a javascript codeblock example\nif (1 > 0) {\n return \'condition is true\';\n}'; - } - return '
// a javascript codeblock example
 if (1 > 0) {
 console.log(\'condition is true\');
 }
'; - }, - }, - }; - - const codeBlockMarkdown = "test\n```\n// a javascript codeblock example\nif (1 > 0) {\n return 'condition is true';\n}\n```"; - - wrapper.instance().pasteHandler(event); - expect(wrapper.state('draft').message).toBe(codeBlockMarkdown); + expect(execCommandInsertText).toHaveBeenCalledWith(codeBlockMarkdown); }); test('should show preview and edit mode, and return focus on preview disable', () => { 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 48d1a976c3..1a8b180bd1 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 @@ -34,14 +34,20 @@ import { groupsMentionedInText, mentionsMinusSpecialMentionsInText, } from 'utils/post_utils'; -import {getTable, hasHtmlLink, formatMarkdownMessage, isGitHubCodeBlock, formatGithubCodePaste, isHttpProtocol, isHttpsProtocol} from 'utils/paste'; -import EmojiMap from 'utils/emoji_map'; import { - applyLinkMarkdown, - ApplyLinkMarkdownOptions, + getHtmlTable, + hasHtmlLink, + formatMarkdownMessage, + isGitHubCodeBlock, + formatGithubCodePaste, + isTextUrl, + formatMarkdownLinkMessage, +} from 'utils/paste'; +import { applyMarkdown, ApplyMarkdownOptions, } from 'utils/markdown/apply_markdown'; +import {execCommandInsertText} from 'utils/exec_commands'; import NotifyConfirmModal from 'components/notify_confirm_modal'; import {FileUpload as FileUploadClass} from 'components/file_upload/file_upload'; @@ -180,13 +186,11 @@ type Props = { getChannelMemberCountsByGroup: (channelID: string, isTimezoneEnabled: boolean) => void; groupsWithAllowReference: Map | null; channelMemberCountsByGroup: ChannelMemberCountsByGroup; - onHeightChange?: (height: number, maxHeight: number) => void; focusOnMount?: boolean; isThreadView?: boolean; openModal:

(modalData: ModalData

) => void; savePreferences: (userId: string, preferences: PreferenceType[]) => ActionResult; useCustomGroupMentions: boolean; - emojiMap: EmojiMap; isFormattingBarHidden: boolean; searchAssociatedGroupsForReference: (prefix: string, teamId: string, channelId: string | undefined) => Promise<{ data: any }>; } @@ -421,66 +425,41 @@ class AdvancedCreateComment extends React.PureComponent { }); }; - pasteHandler = (e: ClipboardEvent) => { - // we need to cast the TextboxElement type onto the EventTarget here since the ClipboardEvent is not generic - if (!e.clipboardData || !e.clipboardData.items || (e.target as TextboxElement).id !== 'reply_textbox') { + pasteHandler = (event: ClipboardEvent) => { + const {clipboardData, target} = event; + + if (!clipboardData || !clipboardData.items || !target || (target as TextboxElement)?.id !== 'reply_textbox') { return; } - const {clipboardData} = e; - - const target = e.target as TextboxElement; - - const {selectionStart, selectionEnd, value} = target; + const {selectionStart, selectionEnd} = target as TextboxElement; 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 hasTextUrl = isTextUrl(clipboardData); + const hasHTMLLinks = hasHtmlLink(clipboardData); + const htmlTable = getHtmlTable(clipboardData); + const shouldApplyLinkMarkdown = hasSelection && hasTextUrl; + const shouldApplyGithubCodeBlock = htmlTable && isGitHubCodeBlock(htmlTable.className); - const hasLinks = hasHtmlLink(clipboardData); - let table = getTable(clipboardData); - if (!table && !hasLinks && !shouldApplyLinkMarkdown) { + if (!htmlTable && !hasHTMLLinks && !shouldApplyLinkMarkdown) { return; } - e.preventDefault(); + event.preventDefault(); + const message = this.state.draft?.message ?? ''; + + // execCommand's insertText' triggers a 'change' event, hence we need not set respective state explicitly. if (shouldApplyLinkMarkdown) { - this.applyLinkMarkdownWhenPaste({ - selectionStart, - selectionEnd, - message: value, - url: clipboardText, - }); - - return; - } - - table = table as HTMLTableElement; - - const draft = this.state.draft!; - let message = draft.message; - - const caretPosition = this.state.caretPosition || 0; - if (table && isGitHubCodeBlock(table.className)) { - const selectionStart = (e.target as any).selectionStart; - const selectionEnd = (e.target as any).selectionEnd; - const {formattedMessage, formattedCodeBlock} = formatGithubCodePaste({selectionStart, selectionEnd, message, clipboardData}); - const newCaretPosition = caretPosition + formattedCodeBlock.length; - message = formattedMessage; - this.setCaretPosition(newCaretPosition); + const formattedLink = formatMarkdownLinkMessage({selectionStart, selectionEnd, message, clipboardData}); + execCommandInsertText(formattedLink); + } else if (shouldApplyGithubCodeBlock) { + const {formattedCodeBlock} = formatGithubCodePaste({selectionStart, selectionEnd, message, clipboardData}); + execCommandInsertText(formattedCodeBlock); } else { - const originalSize = draft.message.length; - message = formatMarkdownMessage(clipboardData, draft.message.trim(), this.state.caretPosition); - const newCaretPosition = message.length - (originalSize - caretPosition); - this.setCaretPosition(newCaretPosition); + const {formattedMarkdown} = formatMarkdownMessage(clipboardData, message, this.state.caretPosition); + execCommandInsertText(formattedMarkdown); } - - const updatedDraft = {...draft, message}; - - this.handleDraftChange(updatedDraft); - this.setState({draft: updatedDraft}); }; handleNotifyAllConfirmation = () => { @@ -1044,25 +1023,6 @@ 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_comment/index.ts b/webapp/channels/src/components/advanced_create_comment/index.ts index 065683f481..a71a322cb4 100644 --- a/webapp/channels/src/components/advanced_create_comment/index.ts +++ b/webapp/channels/src/components/advanced_create_comment/index.ts @@ -46,7 +46,6 @@ import {setShowPreviewOnCreateComment} from 'actions/views/textbox'; import {openModal} from 'actions/views/modals'; import {searchAssociatedGroupsForReference} from 'actions/views/group'; -import {getEmojiMap} from 'selectors/emojis'; import {canUploadFiles} from 'utils/file_utils'; import AdvancedCreateComment from './advanced_create_comment'; @@ -116,7 +115,6 @@ function makeMapStateToProps() { useLDAPGroupMentions, channelMemberCountsByGroup, useCustomGroupMentions, - emojiMap: getEmojiMap(state), canUploadFiles: canUploadFiles(config), }; }; 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 5c1e824772..f09009ea23 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 @@ -4,17 +4,20 @@ import React from 'react'; import {shallow} from 'enzyme'; -import AdvancedCreatePost from 'components/advanced_create_post/advanced_create_post'; - import {Posts} from 'mattermost-redux/constants'; + import {testComponentForLineBreak} from 'tests/helpers/line_break_helpers'; import {testComponentForMarkdownHotkeys} from 'tests/helpers/markdown_hotkey_helpers.js'; -import * as GlobalActions from 'actions/global_actions'; -import EmojiMap from 'utils/emoji_map'; +import * as GlobalActions from 'actions/global_actions'; + +import EmojiMap from 'utils/emoji_map'; import Constants, {StoragePrefixes, ModalIdentifiers} from 'utils/constants'; import * as Utils from 'utils/utils'; -import AdvanceTextEditor from '../advanced_text_editor/advanced_text_editor'; +import {execCommandInsertText} from 'utils/exec_commands'; + +import AdvancedCreatePost from 'components/advanced_create_post/advanced_create_post'; +import AdvanceTextEditor from 'components/advanced_text_editor/advanced_text_editor'; jest.mock('actions/global_actions', () => ({ emitLocalUserTypingEvent: jest.fn(), @@ -29,6 +32,10 @@ jest.mock('actions/post_actions', () => ({ }), })); +jest.mock('utils/exec_commands', () => ({ + execCommandInsertText: jest.fn(), +})); + const currentTeamIdProp = 'r7rws4y7ppgszym3pdd5kaibfa'; const currentUserIdProp = 'zaktnt8bpbgu8mb6ez9k64r7sa'; const showTutorialTipProp = false; @@ -82,7 +89,6 @@ const actionsProp = { searchAssociatedGroupsForReference: jest.fn(), }; -/* eslint-disable react/prop-types */ function advancedCreatePost({ currentChannel = currentChannelProp, currentTeamId = currentTeamIdProp, @@ -145,7 +151,6 @@ function advancedCreatePost({ /> ); } -/* eslint-enable react/prop-types */ describe('components/advanced_create_post', () => { jest.useFakeTimers('legacy'); @@ -1288,7 +1293,7 @@ describe('components/advanced_create_post', () => { const markdownTable = '| test | test |\n| --- | --- |\n| test | test |'; wrapper.instance().pasteHandler(event); - expect(wrapper.state('message')).toBe(markdownTable); + expect(execCommandInsertText).toHaveBeenCalledWith(markdownTable); }); it('should be able to format a pasted markdown table without headers', () => { @@ -1318,7 +1323,7 @@ describe('components/advanced_create_post', () => { const markdownTable = '| test | test |\n| --- | --- |\n| test | test |\n'; wrapper.instance().pasteHandler(event); - expect(wrapper.state('message')).toBe(markdownTable); + expect(execCommandInsertText).toHaveBeenCalledWith(markdownTable); }); it('should be able to format a pasted hyperlink', () => { @@ -1348,51 +1353,7 @@ describe('components/advanced_create_post', () => { const markdownLink = '[link text](https://test.domain)'; wrapper.instance().pasteHandler(event); - expect(wrapper.state('message')).toBe(markdownLink); - }); - - it('should preserve the original message after pasting a markdown table', () => { - const wrapper = shallow(advancedCreatePost()); - - const message = 'original message'; - wrapper.setState({ - message, - caretPosition: message.length, - }); - - const event = { - target: { - id: 'post_textbox', - }, - preventDefault: jest.fn(), - clipboardData: { - items: [1], - types: ['text/html'], - getData: () => { - return '
testtest
testtest
'; - }, - }, - }; - - const markdownTable = '| test | test |\n| --- | --- |\n| test | test |\n\n'; - const expectedMessage = `${message}\n\n${markdownTable}`; - - const mockTop = () => { - return document.createElement('div'); - }; - - const mockImpl = () => { - return { - setSelectionRange: jest.fn(), - getBoundingClientRect: jest.fn(mockTop), - focus: jest.fn(), - }; - }; - - wrapper.instance().textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()}; - - wrapper.instance().pasteHandler(event); - expect(wrapper.state('message')).toBe(expectedMessage); + expect(execCommandInsertText).toHaveBeenCalledWith(markdownLink); }); it('should be able to format a github codeblock (pasted as a table)', () => { @@ -1425,95 +1386,7 @@ describe('components/advanced_create_post', () => { const codeBlockMarkdown = "```\n// a javascript codeblock example\nif (1 > 0) {\n return 'condition is true';\n}\n```"; wrapper.instance().pasteHandler(event); - expect(wrapper.state('message')).toBe(codeBlockMarkdown); - }); - - it('should be able to format a github codeblock (pasted as a table) with existing draft post', () => { - const wrapper = shallow(advancedCreatePost()); - const mockImpl = () => { - return { - setSelectionRange: jest.fn(), - focus: jest.fn(), - }; - }; - wrapper.instance().textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()}; - wrapper.setState({ - message: 'test', - caretPosition: 'test'.length, // cursor is at the end - }); - - const event = { - target: { - id: 'post_textbox', - }, - preventDefault: jest.fn(), - clipboardData: { - items: [1], - types: ['text/plain', 'text/html'], - getData: (type) => { - if (type === 'text/plain') { - return '// a javascript codeblock example\nif (1 > 0) {\n return \'condition is true\';\n}'; - } - return '
// a javascript codeblock example
 if (1 > 0) {
 console.log(\'condition is true\');
 }
'; - }, - }, - }; - - const codeBlockMarkdown = "test\n```\n// a javascript codeblock example\nif (1 > 0) {\n return 'condition is true';\n}\n```"; - - wrapper.instance().pasteHandler(event); - expect(wrapper.state('message')).toBe(codeBlockMarkdown); - }); - - it('should call handlePostPasteDraft to update the draft after pasting', () => { - const wrapper = shallow(advancedCreatePost()); - const mockImpl = () => { - return { - setSelectionRange: jest.fn(), - focus: jest.fn(), - }; - }; - wrapper.instance().textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()}; - wrapper.instance().handlePostPasteDraft = jest.fn(); - - const event = { - target: { - id: 'post_textbox', - }, - preventDefault: jest.fn(), - clipboardData: { - items: [1], - types: ['text/html'], - getData: () => { - return 'link text'; - }, - }, - }; - - wrapper.instance().pasteHandler(event); - expect(wrapper.instance().handlePostPasteDraft).toHaveBeenCalledTimes(1); - }); - - it('should update draft when handlePostPasteDraft is called', () => { - const setDraft = jest.fn(); - - const wrapper = shallow( - advancedCreatePost({ - actions: { - ...actionsProp, - setDraft, - }, - }), - ); - - const testMessage = 'test'; - const expectedDraft = { - ...draftProp, - message: testMessage, - }; - - wrapper.instance().handlePostPasteDraft(testMessage); - expect(setDraft).toHaveBeenCalledWith(StoragePrefixes.DRAFT + currentChannelProp.id, expectedDraft, currentChannelProp.id); + expect(execCommandInsertText).toHaveBeenCalledWith(codeBlockMarkdown); }); /** 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 32d5ee2a85..2704580b28 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 @@ -4,13 +4,21 @@ /* eslint-disable max-lines */ import React from 'react'; - 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'; +import {Channel, ChannelMemberCountsByGroup} from '@mattermost/types/channels'; +import {Post, PostMetadata, PostPriority, PostPriorityMetadata} from '@mattermost/types/posts'; +import {PreferenceType} from '@mattermost/types/preferences'; +import {ServerError} from '@mattermost/types/errors'; +import {CommandArgs} from '@mattermost/types/integrations'; +import {Group, GroupSource} from '@mattermost/types/groups'; +import {FileInfo} from '@mattermost/types/files'; +import {Emoji} from '@mattermost/types/emojis'; + import * as GlobalActions from 'actions/global_actions'; import Constants, { StoragePrefixes, @@ -32,11 +40,20 @@ import { mentionsMinusSpecialMentionsInText, hasRequestedPersistentNotifications, } from 'utils/post_utils'; -import {getTable, hasHtmlLink, formatMarkdownMessage, formatGithubCodePaste, isGitHubCodeBlock, isHttpProtocol, isHttpsProtocol} from 'utils/paste'; +import { + getHtmlTable, + hasHtmlLink, + formatMarkdownMessage, + formatGithubCodePaste, + isGitHubCodeBlock, + formatMarkdownLinkMessage, + isTextUrl, +} from 'utils/paste'; import * as UserAgent from 'utils/user_agent'; import * as Utils from 'utils/utils'; import EmojiMap from 'utils/emoji_map'; -import {applyLinkMarkdown, ApplyLinkMarkdownOptions, applyMarkdown, ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; +import {applyMarkdown, ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; +import {execCommandInsertText} from 'utils/exec_commands'; import NotifyConfirmModal from 'components/notify_confirm_modal'; import EditChannelHeaderModal from 'components/edit_channel_header_modal'; @@ -48,24 +65,11 @@ import PostPriorityPickerOverlay from 'components/post_priority/post_priority_pi import PersistNotificationConfirmModal from 'components/persist_notification_confirm_modal'; import {PostDraft} from 'types/store/draft'; - import {ModalData} from 'types/actions'; -import {Channel, ChannelMemberCountsByGroup} from '@mattermost/types/channels'; -import {Post, PostMetadata, PostPriority, PostPriorityMetadata} from '@mattermost/types/posts'; -import {PreferenceType} from '@mattermost/types/preferences'; -import {ServerError} from '@mattermost/types/errors'; -import {CommandArgs} from '@mattermost/types/integrations'; -import {Group, GroupSource} from '@mattermost/types/groups'; -import {FileInfo} from '@mattermost/types/files'; -import {Emoji} from '@mattermost/types/emojis'; - import AdvancedTextEditor from '../advanced_text_editor/advanced_text_editor'; - import FileLimitStickyBanner from '../file_limit_sticky_banner'; - import {FilePreviewInfo} from '../file_preview/file_preview'; - import PriorityLabels from './priority_labels'; const KeyCodes = Constants.KeyCodes; @@ -74,15 +78,6 @@ function isDraftEmpty(draft: PostDraft): boolean { return !draft || (!draft.message && draft.fileInfos.length === 0); } -// Temporary fix for IE-11, see MM-13423 -function trimRight(str: string) { - if (String.prototype.trimRight as any) { - return str.trimRight(); - } - - return str.replace(/\s*$/, ''); -} - type TextboxElement = HTMLInputElement | HTMLTextAreaElement; type Props = { @@ -713,7 +708,7 @@ class AdvancedCreatePost extends React.PureComponent { return; } - if (trimRight(this.state.message) === '/header') { + if (this.state.message.trimEnd() === '/header') { const editChannelHeaderModalData = { modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER, dialogType: EditChannelHeaderModal, @@ -727,7 +722,7 @@ class AdvancedCreatePost extends React.PureComponent { return; } - if (!isDirectOrGroup && trimRight(this.state.message) === '/purpose') { + if (!isDirectOrGroup && this.state.message.trimEnd() === '/purpose') { const editChannelPurposeModalData = { modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE, dialogType: EditChannelPurposeModal, @@ -919,74 +914,41 @@ class AdvancedCreatePost extends React.PureComponent { this.draftsForChannel[channelId] = draft; }; - pasteHandler = (e: ClipboardEvent) => { - /** - * casting HTMLInputElement on the EventTarget was necessary here - * since the ClipboardEvent type is not generic - */ - if (!e.clipboardData || !e.clipboardData.items || ((e.target as TextboxElement)?.id !== 'post_textbox')) { + pasteHandler = (event: ClipboardEvent) => { + const {clipboardData, target} = event; + + if (!clipboardData || !clipboardData.items || !target || ((target as TextboxElement)?.id !== 'post_textbox')) { return; } - const {clipboardData} = e; - - const target = e.target as TextboxElement; - - const {selectionStart, selectionEnd, value} = target; + const {selectionStart, selectionEnd} = target as TextboxElement; 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 hasTextUrl = isTextUrl(clipboardData); + const hasHTMLLinks = hasHtmlLink(clipboardData); + const htmlTable = getHtmlTable(clipboardData); + const shouldApplyLinkMarkdown = hasSelection && hasTextUrl; + const shouldApplyGithubCodeBlock = htmlTable && isGitHubCodeBlock(htmlTable.className); - const hasLinks = hasHtmlLink(clipboardData); - let table = getTable(clipboardData); - - if (!table && !hasLinks && !shouldApplyLinkMarkdown) { + if (!htmlTable && !hasHTMLLinks && !shouldApplyLinkMarkdown) { return; } - e.preventDefault(); - - if (shouldApplyLinkMarkdown) { - this.applyLinkMarkdownWhenPaste({ - selectionStart, - selectionEnd, - message: value, - url: clipboardText, - }); - - return; - } - - table = table as HTMLTableElement; + event.preventDefault(); const message = this.state.message; - if (table && isGitHubCodeBlock(table.className)) { - const selectionStart = (e.target as any).selectionStart; - const selectionEnd = (e.target as any).selectionEnd; - const {formattedMessage, formattedCodeBlock} = formatGithubCodePaste({selectionStart, selectionEnd, message, clipboardData}); - const newCaretPosition = this.state.caretPosition + formattedCodeBlock.length; - this.setMessageAndCaretPostion(formattedMessage, newCaretPosition); - return; + + // execCommand's insertText' triggers a 'change' event, hence we need not set respective state explicitly. + if (shouldApplyLinkMarkdown) { + const formattedLink = formatMarkdownLinkMessage({selectionStart, selectionEnd, message, clipboardData}); + execCommandInsertText(formattedLink); + } else if (shouldApplyGithubCodeBlock) { + const {formattedCodeBlock} = formatGithubCodePaste({selectionStart, selectionEnd, message, clipboardData}); + execCommandInsertText(formattedCodeBlock); + } else { + const {formattedMarkdown} = formatMarkdownMessage(clipboardData, message, this.state.caretPosition); + execCommandInsertText(formattedMarkdown); } - - const originalSize = message.length; - const formattedMessage = formatMarkdownMessage(clipboardData, message.trim(), this.state.caretPosition); - const newCaretPosition = formattedMessage.length - (originalSize - this.state.caretPosition); - this.setMessageAndCaretPostion(formattedMessage, newCaretPosition); - this.handlePostPasteDraft(formattedMessage); - }; - - handlePostPasteDraft = (message: string) => { - const draft = { - ...this.props.draft, - message, - }; - - const channelId = this.props.currentChannel.id; - this.props.actions.setDraft(StoragePrefixes.DRAFT + channelId, draft, channelId); - this.draftsForChannel[channelId] = draft; }; handleFileUploadChange = () => { @@ -1410,24 +1372,6 @@ 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/edit_post/edit_post.tsx b/webapp/channels/src/components/edit_post/edit_post.tsx index 81c9ea4667..fd30647e9a 100644 --- a/webapp/channels/src/components/edit_post/edit_post.tsx +++ b/webapp/channels/src/components/edit_post/edit_post.tsx @@ -14,7 +14,7 @@ import * as Keyboard from 'utils/keyboard'; import { formatGithubCodePaste, formatMarkdownMessage, - getTable, + getHtmlTable, hasHtmlLink, isGitHubCodeBlock, } from 'utils/paste'; @@ -163,7 +163,7 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, } const hasLinks = hasHtmlLink(clipboardData); - const table = getTable(clipboardData); + const table = getHtmlTable(clipboardData); if (!table && !hasLinks) { return; } @@ -178,7 +178,7 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, message = formattedMessage; newCaretPosition = selectionRange.start + formattedCodeBlock.length; } else { - message = formatMarkdownMessage(clipboardData, editText.trim(), newCaretPosition); + message = formatMarkdownMessage(clipboardData, editText.trim(), newCaretPosition).formattedMessage; newCaretPosition = message.length - (editText.length - newCaretPosition); } diff --git a/webapp/channels/src/components/file_upload/file_upload.tsx b/webapp/channels/src/components/file_upload/file_upload.tsx index a41f617af5..501b890014 100644 --- a/webapp/channels/src/components/file_upload/file_upload.tsx +++ b/webapp/channels/src/components/file_upload/file_upload.tsx @@ -18,7 +18,7 @@ import { isIosChrome, isMobileApp, } from 'utils/user_agent'; -import {getTable} from 'utils/paste'; +import {getHtmlTable} from 'utils/paste'; import { clearFileInput, generateId, @@ -451,7 +451,7 @@ export class FileUpload extends PureComponent { pasteUpload = (e: ClipboardEvent) => { const {formatMessage} = this.props.intl; - if (!e.clipboardData || !e.clipboardData.items || getTable(e.clipboardData)) { + if (!e.clipboardData || !e.clipboardData.items || getHtmlTable(e.clipboardData)) { return; } diff --git a/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx b/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx index 0ce347566e..f5e100376b 100644 --- a/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx +++ b/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx @@ -20,7 +20,6 @@ import BasicSeparator from 'components/widgets/separator/basic-separator'; type Props = { focusOnMount: boolean; - onHeightChange: (height: number, maxHeight: number) => void; teammate?: UserProfile; threadId: string; latestPostId: Post['id']; @@ -29,7 +28,6 @@ type Props = { const CreateComment = forwardRef(({ focusOnMount, - onHeightChange, teammate, threadId, latestPostId, @@ -97,7 +95,6 @@ const CreateComment = forwardRef(({ focusOnMount={focusOnMount} channelId={channel.id} latestPostId={latestPostId} - onHeightChange={onHeightChange} rootDeleted={rootDeleted} rootId={threadId} isThreadView={isThreadView} diff --git a/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.tsx b/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.tsx index 66c6d2ca6a..47ca3d60ed 100644 --- a/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.tsx +++ b/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.tsx @@ -63,8 +63,6 @@ const innerStyles = { paddingTop: '28px', }; -const CREATE_COMMENT_BUTTON_HEIGHT = 81; - const THREADING_TIME: typeof BASE_THREADING_TIME = { ...BASE_THREADING_TIME, units: [ @@ -334,18 +332,6 @@ class ThreadViewerVirtualized extends PureComponent { } }; - handleCreateCommentHeightChange = (height: number, maxHeight: number) => { - let createCommentHeight = height > maxHeight ? maxHeight : height; - createCommentHeight += CREATE_COMMENT_BUTTON_HEIGHT; - - if (createCommentHeight !== this.state.createCommentHeight) { - this.setState({createCommentHeight}); - if (this.state.userScrolledToBottom) { - this.scrollToBottom(); - } - } - }; - renderRow = ({data, itemId, style}: {data: any; itemId: any; style: any}) => { const index = data.indexOf(itemId); let className = ''; @@ -379,7 +365,6 @@ class ThreadViewerVirtualized extends PureComponent { focusOnMount={!this.props.isThreadView && (this.state.userScrolledToBottom || (!this.state.userScrolled && this.getInitialPostIndex() === 0))} isThreadView={this.props.isThreadView} latestPostId={this.props.lastPost.id} - onHeightChange={this.handleCreateCommentHeightChange} ref={this.postCreateContainerRef} teammate={this.props.directTeammate} threadId={this.props.selected.id} diff --git a/webapp/channels/src/tests/setup.js b/webapp/channels/src/tests/setup.js index 93ac19da01..b37e401826 100644 --- a/webapp/channels/src/tests/setup.js +++ b/webapp/channels/src/tests/setup.js @@ -29,7 +29,7 @@ Object.defineProperty(window, 'location', { }, }); -const supportedCommands = ['copy']; +const supportedCommands = ['copy', 'insertText']; Object.defineProperty(document, 'queryCommandSupported', { value: (cmd) => supportedCommands.includes(cmd), diff --git a/webapp/channels/src/utils/exec_commands.ts b/webapp/channels/src/utils/exec_commands.ts new file mode 100644 index 0000000000..43eab97701 --- /dev/null +++ b/webapp/channels/src/utils/exec_commands.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/** + * This is a wrapper around document.execCommand('insertText', false, text) to insert test into the focused element. + * @param text The text to insert. + */ +export function execCommandInsertText(text: string) { + document.execCommand('insertText', false, text); +} diff --git a/webapp/channels/src/utils/paste.test.tsx b/webapp/channels/src/utils/paste.test.tsx index 680e1fcc32..13a6370b1a 100644 --- a/webapp/channels/src/utils/paste.test.tsx +++ b/webapp/channels/src/utils/paste.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {parseTable, getTable, formatMarkdownMessage, formatGithubCodePaste} from './paste'; +import {parseHtmlTable, getHtmlTable, formatMarkdownMessage, formatGithubCodePaste, formatMarkdownLinkMessage, isTextUrl} from './paste'; const validClipboardData: any = { items: [1], @@ -11,16 +11,16 @@ const validClipboardData: any = { }, }; -const validTable: any = parseTable(validClipboardData.getData()); +const validTable: any = parseHtmlTable(validClipboardData.getData()); -describe('Paste.getTable', () => { +describe('getHtmlTable', () => { test('returns false without html in the clipboard', () => { const badClipboardData: any = { items: [1], types: ['text/plain'], }; - expect(getTable(badClipboardData)).toBe(null); + expect(getHtmlTable(badClipboardData)).toBe(null); }); test('returns false without table in the clipboard', () => { @@ -30,19 +30,19 @@ describe('Paste.getTable', () => { getData: () => '

There is no table here

', }; - expect(getTable(badClipboardData)).toBe(null); + expect(getHtmlTable(badClipboardData)).toBe(null); }); test('returns table from valid clipboard data', () => { - expect(getTable(validClipboardData)).toEqual(validTable); + expect(getHtmlTable(validClipboardData)).toEqual(validTable); }); }); -describe('Paste.formatMarkdownMessage', () => { +describe('formatMarkdownMessage', () => { const markdownTable = '| test | test |\n| --- | --- |\n| test | test |'; test('returns a markdown table when valid html table provided', () => { - expect(formatMarkdownMessage(validClipboardData)).toBe(`${markdownTable}\n`); + expect(formatMarkdownMessage(validClipboardData).formattedMessage).toBe(`${markdownTable}\n`); }); test('returns a markdown table when valid html table with headers provided', () => { @@ -54,7 +54,7 @@ describe('Paste.formatMarkdownMessage', () => { }, }; - expect(formatMarkdownMessage(tableHeadersClipboardData)).toBe(markdownTable); + expect(formatMarkdownMessage(tableHeadersClipboardData).formattedMessage).toBe(markdownTable); }); test('removes style contents and additional whitespace around tables', () => { @@ -66,13 +66,13 @@ describe('Paste.formatMarkdownMessage', () => { }, }; - expect(formatMarkdownMessage(styleClipboardData)).toBe(markdownTable); + expect(formatMarkdownMessage(styleClipboardData).formattedMessage).toBe(markdownTable); }); test('returns a markdown table under a message when one is provided', () => { const testMessage = 'test message'; - expect(formatMarkdownMessage(validClipboardData, testMessage)).toBe(`${testMessage}\n\n${markdownTable}\n`); + expect(formatMarkdownMessage(validClipboardData, testMessage).formattedMessage).toBe(`${testMessage}\n\n${markdownTable}\n`); }); test('returns a markdown formatted link when valid hyperlink provided', () => { @@ -85,11 +85,11 @@ describe('Paste.formatMarkdownMessage', () => { }; const markdownLink = '[link text](https://test.domain)'; - expect(formatMarkdownMessage(linkClipboardData)).toBe(markdownLink); + expect(formatMarkdownMessage(linkClipboardData).formattedMessage).toBe(markdownLink); }); }); -describe('Paste.formatGithubCodePaste', () => { +describe('formatGithubCodePaste', () => { const clipboardData: any = { items: [], types: ['text/plain', 'text/html'], @@ -147,3 +147,50 @@ describe('Paste.formatGithubCodePaste', () => { expect(codeBlock).toBe(formattedCodeBlock); }); }); + +describe('formatMarkdownLinkMessage', () => { + const clipboardData: any = { + items: [], + types: ['text/plain'], + getData: () => { + return 'https://example.com/'; + }, + }; + + test('Should return empty selection when no selection is made', () => { + const message = ''; + + const formatttedMarkdownLinkMessage = formatMarkdownLinkMessage({selectionStart: 0, selectionEnd: 0, message, clipboardData}); + expect(formatttedMarkdownLinkMessage).toEqual('[](https://example.com/)'); + }); + + test('Should return correct selection when selection is made', () => { + const message = 'test'; + + const formatttedMarkdownLinkMessage = formatMarkdownLinkMessage({selectionStart: 0, selectionEnd: 4, message, clipboardData}); + expect(formatttedMarkdownLinkMessage).toEqual('[test](https://example.com/)'); + }); +}); + +describe('isTextUrl', () => { + test('Should return true when url is valid', () => { + const clipboardData: any = { + ...validClipboardData, + getData: () => { + return 'https://example.com/'; + }, + }; + expect(isTextUrl(clipboardData)).toBe(true); + }); + + test('Should return false when url is invalid', () => { + const clipboardData: any = { + ...validClipboardData, + getData: () => { + return 'not a url'; + }, + }; + + expect(isTextUrl(clipboardData)).toBe(false); + }); +}); diff --git a/webapp/channels/src/utils/paste.tsx b/webapp/channels/src/utils/paste.tsx index bfbc76bea1..dc415f2ea2 100644 --- a/webapp/channels/src/utils/paste.tsx +++ b/webapp/channels/src/utils/paste.tsx @@ -1,23 +1,21 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import TurndownService from 'turndown'; -import {tables} from '@guyplusplus/turndown-plugin-gfm'; - +import turndownService from 'utils/turndown'; import {splitMessageBasedOnCaretPosition, splitMessageBasedOnTextSelection} from 'utils/post_utils'; -type FormatCodeOptions = { +type FormatMarkdownParams = { message: string; clipboardData: DataTransfer; selectionStart: number | null; selectionEnd: number | null; }; -export function parseTable(html: string): HTMLTableElement | null { +export function parseHtmlTable(html: string): HTMLTableElement | null { return new DOMParser().parseFromString(html, 'text/html').querySelector('table'); } -export function getTable(clipboardData: DataTransfer): HTMLTableElement | null { +export function getHtmlTable(clipboardData: DataTransfer): HTMLTableElement | null { if (Array.from(clipboardData.types).indexOf('text/html') === -1) { return null; } @@ -28,7 +26,7 @@ export function getTable(clipboardData: DataTransfer): HTMLTableElement | null { return null; } - const table = parseTable(html); + const table = parseHtmlTable(html); if (!table) { return null; } @@ -40,66 +38,87 @@ export function hasHtmlLink(clipboardData: DataTransfer): boolean { return Array.from(clipboardData.types).includes('text/html') && (/