MM-49368 : Pasting non plain text clears undo history of Textbox (#23535)

This commit is contained in:
M-ZubairAhmed 2023-06-22 23:33:03 +05:30 committed by GitHub
parent ba4dc1a91c
commit b9cd2a9814
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 255 additions and 460 deletions

View File

@ -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(
<AdvancedCreateComment
@ -1354,10 +1359,10 @@ describe('components/AdvancedCreateComment', () => {
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(
<AdvancedCreateComment
@ -1397,10 +1402,10 @@ describe('components/AdvancedCreateComment', () => {
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(
<AdvancedCreateComment
@ -1440,10 +1445,10 @@ describe('components/AdvancedCreateComment', () => {
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(
<AdvancedCreateComment
@ -1486,60 +1491,7 @@ describe('components/AdvancedCreateComment', () => {
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(
<AdvancedCreateComment
{...baseProps}
draft={draft}
/>,
);
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 '<table class="highlight tab-size js-file-line-container" data-tab-size="8"><tbody><tr><td id="LC1" class="blob-code blob-code-inner js-file-line"><span class="pl-c"><span class="pl-c">//</span> a javascript codeblock example</span></td></tr><tr><td id="L2" class="blob-num js-line-number" data-line-number="2">&nbsp;</td><td id="LC2" class="blob-code blob-code-inner js-file-line"><span class="pl-k">if</span> (<span class="pl-c1">1</span> <span class="pl-k">&gt;</span> <span class="pl-c1">0</span>) {</td></tr><tr><td id="L3" class="blob-num js-line-number" data-line-number="3">&nbsp;</td><td id="LC3" class="blob-code blob-code-inner js-file-line"><span class="pl-en">console</span>.<span class="pl-c1">log</span>(<span class="pl-s"><span class="pl-pds">\'</span>condition is true<span class="pl-pds">\'</span></span>);</td></tr><tr><td id="L4" class="blob-num js-line-number" data-line-number="4">&nbsp;</td><td id="LC4" class="blob-code blob-code-inner js-file-line">}</td></tr></tbody></table>';
},
},
};
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', () => {

View File

@ -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<string, Group> | null;
channelMemberCountsByGroup: ChannelMemberCountsByGroup;
onHeightChange?: (height: number, maxHeight: number) => void;
focusOnMount?: boolean;
isThreadView?: boolean;
openModal: <P>(modalData: ModalData<P>) => 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<Props, State> {
});
};
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<Props, State> {
});
};
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();

View File

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

View File

@ -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 '<table><tr><td>test</td><td>test</td></tr><tr><td>test</td><td>test</td></tr></table>';
},
},
};
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 '<table class="highlight tab-size js-file-line-container" data-tab-size="8"><tbody><tr><td id="LC1" class="blob-code blob-code-inner js-file-line"><span class="pl-c"><span class="pl-c">//</span> a javascript codeblock example</span></td></tr><tr><td id="L2" class="blob-num js-line-number" data-line-number="2">&nbsp;</td><td id="LC2" class="blob-code blob-code-inner js-file-line"><span class="pl-k">if</span> (<span class="pl-c1">1</span> <span class="pl-k">&gt;</span> <span class="pl-c1">0</span>) {</td></tr><tr><td id="L3" class="blob-num js-line-number" data-line-number="3">&nbsp;</td><td id="LC3" class="blob-code blob-code-inner js-file-line"><span class="pl-en">console</span>.<span class="pl-c1">log</span>(<span class="pl-s"><span class="pl-pds">\'</span>condition is true<span class="pl-pds">\'</span></span>);</td></tr><tr><td id="L4" class="blob-num js-line-number" data-line-number="4">&nbsp;</td><td id="LC4" class="blob-code blob-code-inner js-file-line">}</td></tr></tbody></table>';
},
},
};
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 '<a href="https://test.domain">link text</a>';
},
},
};
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);
});
/**

View File

@ -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<Props, State> {
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<Props, State> {
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<Props, State> {
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<Props, State> {
});
};
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();

View File

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

View File

@ -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<Props, State> {
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;
}

View File

@ -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<HTMLDivElement, Props>(({
focusOnMount,
onHeightChange,
teammate,
threadId,
latestPostId,
@ -97,7 +95,6 @@ const CreateComment = forwardRef<HTMLDivElement, Props>(({
focusOnMount={focusOnMount}
channelId={channel.id}
latestPostId={latestPostId}
onHeightChange={onHeightChange}
rootDeleted={rootDeleted}
rootId={threadId}
isThreadView={isThreadView}

View File

@ -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<Props, State> {
}
};
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<Props, State> {
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}

View File

@ -29,7 +29,7 @@ Object.defineProperty(window, 'location', {
},
});
const supportedCommands = ['copy'];
const supportedCommands = ['copy', 'insertText'];
Object.defineProperty(document, 'queryCommandSupported', {
value: (cmd) => supportedCommands.includes(cmd),

View File

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

View File

@ -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: () => '<p>There is no table here</p>',
};
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);
});
});

View File

@ -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') && (/<a/i).test(clipboardData.getData('text/html'));
}
export function getPlainText(clipboardData: DataTransfer): string | boolean {
if (Array.from(clipboardData.types).indexOf('text/plain') === -1) {
return false;
}
const plainText = clipboardData.getData('text/plain');
return plainText;
}
export function isGitHubCodeBlock(tableClassName: string): boolean {
const result = (/\b(js|blob|diff)-./).test(tableClassName);
return result;
}
export function isHttpProtocol(url: string): boolean {
return url.startsWith('http://');
export function isTextUrl(clipboardData: DataTransfer): boolean {
const clipboardText = clipboardData.getData('text/plain');
return clipboardText.startsWith('http://') || clipboardText.startsWith('https://');
}
export function isHttpsProtocol(url: string): boolean {
return url.startsWith('https://');
}
function isHeaderlessTable(table: HTMLTableElement): boolean {
function isTableWithoutHeaderRow(table: HTMLTableElement): boolean {
return table.querySelectorAll('th').length === 0;
}
export function formatMarkdownMessage(clipboardData: DataTransfer, message?: string, caretPosition?: number): string {
/**
* Formats the given HTML clipboard data into a Markdown message.
* @returns {Object} An object containing 'formattedMessage' and 'formattedMarkdown'.
* @property {string} formattedMessage - The formatted message, including the formatted Markdown.
* @property {string} formattedMarkdown - The resulting Markdown from the HTML clipboard data.
*/
export function formatMarkdownMessage(clipboardData: DataTransfer, message?: string, caretPosition?: number): {formattedMessage: string; formattedMarkdown: string} {
const html = clipboardData.getData('text/html');
//TODO@michel: Instantiate turndown service in a central file instead
const service = new TurndownService({emDelimiter: '*'}).remove('style');
service.use(tables);
let markdownFormattedMessage = service.turndown(html).trim();
let formattedMarkdown = turndownService.turndown(html).trim();
const table = getTable(clipboardData);
if (table && isHeaderlessTable(table)) {
markdownFormattedMessage += '\n';
const table = getHtmlTable(clipboardData);
if (table && isTableWithoutHeaderRow(table)) {
const newLineLimiter = '\n';
formattedMarkdown = `${formattedMarkdown}${newLineLimiter}`;
}
let formattedMessage: string;
if (!message) {
return markdownFormattedMessage;
formattedMessage = formattedMarkdown;
} else if (typeof caretPosition === 'undefined') {
formattedMessage = `${message}\n\n${formattedMarkdown}`;
} else {
const newMessage = [message.slice(0, caretPosition) + '\n', formattedMarkdown, message.slice(caretPosition)];
formattedMessage = newMessage.join('');
}
if (typeof caretPosition === 'undefined') {
return `${message}\n\n${markdownFormattedMessage}`;
}
const newMessage = [message.slice(0, caretPosition) + '\n', markdownFormattedMessage, message.slice(caretPosition)];
return newMessage.join('\n');
return {formattedMessage, formattedMarkdown};
}
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);
/**
* Format the incoming github code paste into a markdown code block.
* This function assumes that the clipboardData contains a code block.
* @returns {Object} An object containing the 'formattedMessage' and 'formattedCodeBlock'.
* @property {string} formattedMessage - The complete formatted message including the code block.
* @property {string} formattedCodeBlock - The resulting code block from the clipboard data.
*/
export function formatGithubCodePaste({message, clipboardData, selectionStart, selectionEnd}: FormatMarkdownParams): {formattedMessage: string; formattedCodeBlock: string} {
const isTextSelected = selectionStart !== selectionEnd;
const {firstPiece, lastPiece} = isTextSelected ? 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';
const requireEndLF = lastPiece === '' ? '' : '\n';
const formattedCodeBlock = requireStartLF + '```\n' + getPlainText(clipboardData) + '\n```' + requireEndLF;
const clipboardText = clipboardData.getData('text/plain');
const formattedCodeBlock = requireStartLF + '```\n' + clipboardText + '\n```' + requireEndLF;
const formattedMessage = `${firstPiece}${formattedCodeBlock}${lastPiece}`;
return {formattedMessage, formattedCodeBlock};
}
/**
* Formats the incoming link paste into a markdown link.
* This function assumes that the clipboardData contains a link.
* @returns The resulting markdown link from the clipboard data.
*/
export function formatMarkdownLinkMessage({message, clipboardData, selectionStart, selectionEnd}: FormatMarkdownParams) {
const isTextSelected = selectionStart !== selectionEnd;
let selectedText = '';
if (isTextSelected) {
selectedText = message.slice(selectionStart || 0, selectionEnd || 0);
}
const url = clipboardData.getData('text/plain');
const markdownLink = `[${selectedText}](${url})`;
return markdownLink;
}

View File

@ -0,0 +1,10 @@
// 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';
const turndownService = new TurndownService({emDelimiter: '*'}).remove('style');
turndownService.use(tables);
export default turndownService;