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 {testComponentForMarkdownHotkeys} from 'tests/helpers/markdown_hotkey_helpers.js';
import Constants, {ModalIdentifiers} from 'utils/constants'; import Constants, {ModalIdentifiers} from 'utils/constants';
import {execCommandInsertText} from 'utils/exec_commands';
import AdvancedCreateComment from 'components/advanced_create_comment/advanced_create_comment'; 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', () => { describe('components/AdvancedCreateComment', () => {
jest.useFakeTimers(); jest.useFakeTimers();
@ -1314,7 +1319,7 @@ describe('components/AdvancedCreateComment', () => {
expect(scrollToBottom).toBeCalledTimes(2); 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 draft = emptyDraft;
const wrapper = shallow( const wrapper = shallow(
<AdvancedCreateComment <AdvancedCreateComment
@ -1354,10 +1359,10 @@ describe('components/AdvancedCreateComment', () => {
const markdownTable = '| test | test |\n| --- | --- |\n| test | test |'; const markdownTable = '| test | test |\n| --- | --- |\n| test | test |';
wrapper.instance().pasteHandler(event); 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 draft = emptyDraft;
const wrapper = shallow( const wrapper = shallow(
<AdvancedCreateComment <AdvancedCreateComment
@ -1397,10 +1402,10 @@ describe('components/AdvancedCreateComment', () => {
const markdownTable = '| test | test |\n| --- | --- |\n| test | test |\n'; const markdownTable = '| test | test |\n| --- | --- |\n| test | test |\n';
wrapper.instance().pasteHandler(event); 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 draft = emptyDraft;
const wrapper = shallow( const wrapper = shallow(
<AdvancedCreateComment <AdvancedCreateComment
@ -1440,10 +1445,10 @@ describe('components/AdvancedCreateComment', () => {
const markdownLink = '[link text](https://test.domain)'; const markdownLink = '[link text](https://test.domain)';
wrapper.instance().pasteHandler(event); 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 draft = emptyDraft;
const wrapper = shallow( const wrapper = shallow(
<AdvancedCreateComment <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```"; const codeBlockMarkdown = "```\n// a javascript codeblock example\nif (1 > 0) {\n return 'condition is true';\n}\n```";
wrapper.instance().pasteHandler(event); wrapper.instance().pasteHandler(event);
expect(wrapper.state('draft').message).toBe(codeBlockMarkdown); expect(execCommandInsertText).toHaveBeenCalledWith(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);
}); });
test('should show preview and edit mode, and return focus on preview disable', () => { test('should show preview and edit mode, and return focus on preview disable', () => {

View File

@ -34,14 +34,20 @@ import {
groupsMentionedInText, groupsMentionedInText,
mentionsMinusSpecialMentionsInText, mentionsMinusSpecialMentionsInText,
} from 'utils/post_utils'; } from 'utils/post_utils';
import {getTable, hasHtmlLink, formatMarkdownMessage, isGitHubCodeBlock, formatGithubCodePaste, isHttpProtocol, isHttpsProtocol} from 'utils/paste';
import EmojiMap from 'utils/emoji_map';
import { import {
applyLinkMarkdown, getHtmlTable,
ApplyLinkMarkdownOptions, hasHtmlLink,
formatMarkdownMessage,
isGitHubCodeBlock,
formatGithubCodePaste,
isTextUrl,
formatMarkdownLinkMessage,
} from 'utils/paste';
import {
applyMarkdown, applyMarkdown,
ApplyMarkdownOptions, ApplyMarkdownOptions,
} from 'utils/markdown/apply_markdown'; } from 'utils/markdown/apply_markdown';
import {execCommandInsertText} from 'utils/exec_commands';
import NotifyConfirmModal from 'components/notify_confirm_modal'; import NotifyConfirmModal from 'components/notify_confirm_modal';
import {FileUpload as FileUploadClass} from 'components/file_upload/file_upload'; import {FileUpload as FileUploadClass} from 'components/file_upload/file_upload';
@ -180,13 +186,11 @@ type Props = {
getChannelMemberCountsByGroup: (channelID: string, isTimezoneEnabled: boolean) => void; getChannelMemberCountsByGroup: (channelID: string, isTimezoneEnabled: boolean) => void;
groupsWithAllowReference: Map<string, Group> | null; groupsWithAllowReference: Map<string, Group> | null;
channelMemberCountsByGroup: ChannelMemberCountsByGroup; channelMemberCountsByGroup: ChannelMemberCountsByGroup;
onHeightChange?: (height: number, maxHeight: number) => void;
focusOnMount?: boolean; focusOnMount?: boolean;
isThreadView?: boolean; isThreadView?: boolean;
openModal: <P>(modalData: ModalData<P>) => void; openModal: <P>(modalData: ModalData<P>) => void;
savePreferences: (userId: string, preferences: PreferenceType[]) => ActionResult; savePreferences: (userId: string, preferences: PreferenceType[]) => ActionResult;
useCustomGroupMentions: boolean; useCustomGroupMentions: boolean;
emojiMap: EmojiMap;
isFormattingBarHidden: boolean; isFormattingBarHidden: boolean;
searchAssociatedGroupsForReference: (prefix: string, teamId: string, channelId: string | undefined) => Promise<{ data: any }>; 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) => { pasteHandler = (event: ClipboardEvent) => {
// we need to cast the TextboxElement type onto the EventTarget here since the ClipboardEvent is not generic const {clipboardData, target} = event;
if (!e.clipboardData || !e.clipboardData.items || (e.target as TextboxElement).id !== 'reply_textbox') {
if (!clipboardData || !clipboardData.items || !target || (target as TextboxElement)?.id !== 'reply_textbox') {
return; return;
} }
const {clipboardData} = e; const {selectionStart, selectionEnd} = target as TextboxElement;
const target = e.target as TextboxElement;
const {selectionStart, selectionEnd, value} = target;
const hasSelection = !isNil(selectionStart) && !isNil(selectionEnd) && selectionStart < selectionEnd; const hasSelection = !isNil(selectionStart) && !isNil(selectionEnd) && selectionStart < selectionEnd;
const clipboardText = clipboardData.getData('text/plain'); const hasTextUrl = isTextUrl(clipboardData);
const isClipboardTextURL = isHttpProtocol(clipboardText) || isHttpsProtocol(clipboardText); const hasHTMLLinks = hasHtmlLink(clipboardData);
const shouldApplyLinkMarkdown = hasSelection && isClipboardTextURL; const htmlTable = getHtmlTable(clipboardData);
const shouldApplyLinkMarkdown = hasSelection && hasTextUrl;
const shouldApplyGithubCodeBlock = htmlTable && isGitHubCodeBlock(htmlTable.className);
const hasLinks = hasHtmlLink(clipboardData); if (!htmlTable && !hasHTMLLinks && !shouldApplyLinkMarkdown) {
let table = getTable(clipboardData);
if (!table && !hasLinks && !shouldApplyLinkMarkdown) {
return; 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) { if (shouldApplyLinkMarkdown) {
this.applyLinkMarkdownWhenPaste({ const formattedLink = formatMarkdownLinkMessage({selectionStart, selectionEnd, message, clipboardData});
selectionStart, execCommandInsertText(formattedLink);
selectionEnd, } else if (shouldApplyGithubCodeBlock) {
message: value, const {formattedCodeBlock} = formatGithubCodePaste({selectionStart, selectionEnd, message, clipboardData});
url: clipboardText, execCommandInsertText(formattedCodeBlock);
});
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);
} else { } else {
const originalSize = draft.message.length; const {formattedMarkdown} = formatMarkdownMessage(clipboardData, message, this.state.caretPosition);
message = formatMarkdownMessage(clipboardData, draft.message.trim(), this.state.caretPosition); execCommandInsertText(formattedMarkdown);
const newCaretPosition = message.length - (originalSize - caretPosition);
this.setCaretPosition(newCaretPosition);
} }
const updatedDraft = {...draft, message};
this.handleDraftChange(updatedDraft);
this.setState({draft: updatedDraft});
}; };
handleNotifyAllConfirmation = () => { 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 = () => { handleFileUploadChange = () => {
this.isDraftEdited = true; this.isDraftEdited = true;
this.focusTextbox(); this.focusTextbox();

View File

@ -46,7 +46,6 @@ import {setShowPreviewOnCreateComment} from 'actions/views/textbox';
import {openModal} from 'actions/views/modals'; import {openModal} from 'actions/views/modals';
import {searchAssociatedGroupsForReference} from 'actions/views/group'; import {searchAssociatedGroupsForReference} from 'actions/views/group';
import {getEmojiMap} from 'selectors/emojis';
import {canUploadFiles} from 'utils/file_utils'; import {canUploadFiles} from 'utils/file_utils';
import AdvancedCreateComment from './advanced_create_comment'; import AdvancedCreateComment from './advanced_create_comment';
@ -116,7 +115,6 @@ function makeMapStateToProps() {
useLDAPGroupMentions, useLDAPGroupMentions,
channelMemberCountsByGroup, channelMemberCountsByGroup,
useCustomGroupMentions, useCustomGroupMentions,
emojiMap: getEmojiMap(state),
canUploadFiles: canUploadFiles(config), canUploadFiles: canUploadFiles(config),
}; };
}; };

View File

@ -4,17 +4,20 @@
import React from 'react'; import React from 'react';
import {shallow} from 'enzyme'; import {shallow} from 'enzyme';
import AdvancedCreatePost from 'components/advanced_create_post/advanced_create_post';
import {Posts} from 'mattermost-redux/constants'; import {Posts} from 'mattermost-redux/constants';
import {testComponentForLineBreak} from 'tests/helpers/line_break_helpers'; import {testComponentForLineBreak} from 'tests/helpers/line_break_helpers';
import {testComponentForMarkdownHotkeys} from 'tests/helpers/markdown_hotkey_helpers.js'; 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 Constants, {StoragePrefixes, ModalIdentifiers} from 'utils/constants';
import * as Utils from 'utils/utils'; 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', () => ({ jest.mock('actions/global_actions', () => ({
emitLocalUserTypingEvent: jest.fn(), emitLocalUserTypingEvent: jest.fn(),
@ -29,6 +32,10 @@ jest.mock('actions/post_actions', () => ({
}), }),
})); }));
jest.mock('utils/exec_commands', () => ({
execCommandInsertText: jest.fn(),
}));
const currentTeamIdProp = 'r7rws4y7ppgszym3pdd5kaibfa'; const currentTeamIdProp = 'r7rws4y7ppgszym3pdd5kaibfa';
const currentUserIdProp = 'zaktnt8bpbgu8mb6ez9k64r7sa'; const currentUserIdProp = 'zaktnt8bpbgu8mb6ez9k64r7sa';
const showTutorialTipProp = false; const showTutorialTipProp = false;
@ -82,7 +89,6 @@ const actionsProp = {
searchAssociatedGroupsForReference: jest.fn(), searchAssociatedGroupsForReference: jest.fn(),
}; };
/* eslint-disable react/prop-types */
function advancedCreatePost({ function advancedCreatePost({
currentChannel = currentChannelProp, currentChannel = currentChannelProp,
currentTeamId = currentTeamIdProp, currentTeamId = currentTeamIdProp,
@ -145,7 +151,6 @@ function advancedCreatePost({
/> />
); );
} }
/* eslint-enable react/prop-types */
describe('components/advanced_create_post', () => { describe('components/advanced_create_post', () => {
jest.useFakeTimers('legacy'); jest.useFakeTimers('legacy');
@ -1288,7 +1293,7 @@ describe('components/advanced_create_post', () => {
const markdownTable = '| test | test |\n| --- | --- |\n| test | test |'; const markdownTable = '| test | test |\n| --- | --- |\n| test | test |';
wrapper.instance().pasteHandler(event); 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', () => { 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'; const markdownTable = '| test | test |\n| --- | --- |\n| test | test |\n';
wrapper.instance().pasteHandler(event); wrapper.instance().pasteHandler(event);
expect(wrapper.state('message')).toBe(markdownTable); expect(execCommandInsertText).toHaveBeenCalledWith(markdownTable);
}); });
it('should be able to format a pasted hyperlink', () => { 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)'; const markdownLink = '[link text](https://test.domain)';
wrapper.instance().pasteHandler(event); wrapper.instance().pasteHandler(event);
expect(wrapper.state('message')).toBe(markdownLink); expect(execCommandInsertText).toHaveBeenCalledWith(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);
}); });
it('should be able to format a github codeblock (pasted as a table)', () => { 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```"; const codeBlockMarkdown = "```\n// a javascript codeblock example\nif (1 > 0) {\n return 'condition is true';\n}\n```";
wrapper.instance().pasteHandler(event); wrapper.instance().pasteHandler(event);
expect(wrapper.state('message')).toBe(codeBlockMarkdown); expect(execCommandInsertText).toHaveBeenCalledWith(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);
}); });
/** /**

View File

@ -4,13 +4,21 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import React from 'react'; import React from 'react';
import {isNil} from 'lodash'; import {isNil} from 'lodash';
import {Posts} from 'mattermost-redux/constants'; import {Posts} from 'mattermost-redux/constants';
import {sortFileInfos} from 'mattermost-redux/utils/file_utils'; import {sortFileInfos} from 'mattermost-redux/utils/file_utils';
import {ActionResult} from 'mattermost-redux/types/actions'; 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 * as GlobalActions from 'actions/global_actions';
import Constants, { import Constants, {
StoragePrefixes, StoragePrefixes,
@ -32,11 +40,20 @@ import {
mentionsMinusSpecialMentionsInText, mentionsMinusSpecialMentionsInText,
hasRequestedPersistentNotifications, hasRequestedPersistentNotifications,
} from 'utils/post_utils'; } 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 UserAgent from 'utils/user_agent';
import * as Utils from 'utils/utils'; import * as Utils from 'utils/utils';
import EmojiMap from 'utils/emoji_map'; 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 NotifyConfirmModal from 'components/notify_confirm_modal';
import EditChannelHeaderModal from 'components/edit_channel_header_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 PersistNotificationConfirmModal from 'components/persist_notification_confirm_modal';
import {PostDraft} from 'types/store/draft'; import {PostDraft} from 'types/store/draft';
import {ModalData} from 'types/actions'; 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 AdvancedTextEditor from '../advanced_text_editor/advanced_text_editor';
import FileLimitStickyBanner from '../file_limit_sticky_banner'; import FileLimitStickyBanner from '../file_limit_sticky_banner';
import {FilePreviewInfo} from '../file_preview/file_preview'; import {FilePreviewInfo} from '../file_preview/file_preview';
import PriorityLabels from './priority_labels'; import PriorityLabels from './priority_labels';
const KeyCodes = Constants.KeyCodes; const KeyCodes = Constants.KeyCodes;
@ -74,15 +78,6 @@ function isDraftEmpty(draft: PostDraft): boolean {
return !draft || (!draft.message && draft.fileInfos.length === 0); 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 TextboxElement = HTMLInputElement | HTMLTextAreaElement;
type Props = { type Props = {
@ -713,7 +708,7 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
return; return;
} }
if (trimRight(this.state.message) === '/header') { if (this.state.message.trimEnd() === '/header') {
const editChannelHeaderModalData = { const editChannelHeaderModalData = {
modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER, modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER,
dialogType: EditChannelHeaderModal, dialogType: EditChannelHeaderModal,
@ -727,7 +722,7 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
return; return;
} }
if (!isDirectOrGroup && trimRight(this.state.message) === '/purpose') { if (!isDirectOrGroup && this.state.message.trimEnd() === '/purpose') {
const editChannelPurposeModalData = { const editChannelPurposeModalData = {
modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE, modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE,
dialogType: EditChannelPurposeModal, dialogType: EditChannelPurposeModal,
@ -919,74 +914,41 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
this.draftsForChannel[channelId] = draft; this.draftsForChannel[channelId] = draft;
}; };
pasteHandler = (e: ClipboardEvent) => { pasteHandler = (event: ClipboardEvent) => {
/** const {clipboardData, target} = event;
* casting HTMLInputElement on the EventTarget was necessary here
* since the ClipboardEvent type is not generic if (!clipboardData || !clipboardData.items || !target || ((target as TextboxElement)?.id !== 'post_textbox')) {
*/
if (!e.clipboardData || !e.clipboardData.items || ((e.target as TextboxElement)?.id !== 'post_textbox')) {
return; return;
} }
const {clipboardData} = e; const {selectionStart, selectionEnd} = target as TextboxElement;
const target = e.target as TextboxElement;
const {selectionStart, selectionEnd, value} = target;
const hasSelection = !isNil(selectionStart) && !isNil(selectionEnd) && selectionStart < selectionEnd; const hasSelection = !isNil(selectionStart) && !isNil(selectionEnd) && selectionStart < selectionEnd;
const clipboardText = clipboardData.getData('text/plain'); const hasTextUrl = isTextUrl(clipboardData);
const isClipboardTextURL = isHttpProtocol(clipboardText) || isHttpsProtocol(clipboardText); const hasHTMLLinks = hasHtmlLink(clipboardData);
const shouldApplyLinkMarkdown = hasSelection && isClipboardTextURL; const htmlTable = getHtmlTable(clipboardData);
const shouldApplyLinkMarkdown = hasSelection && hasTextUrl;
const shouldApplyGithubCodeBlock = htmlTable && isGitHubCodeBlock(htmlTable.className);
const hasLinks = hasHtmlLink(clipboardData); if (!htmlTable && !hasHTMLLinks && !shouldApplyLinkMarkdown) {
let table = getTable(clipboardData);
if (!table && !hasLinks && !shouldApplyLinkMarkdown) {
return; return;
} }
e.preventDefault(); event.preventDefault();
if (shouldApplyLinkMarkdown) {
this.applyLinkMarkdownWhenPaste({
selectionStart,
selectionEnd,
message: value,
url: clipboardText,
});
return;
}
table = table as HTMLTableElement;
const message = this.state.message; const message = this.state.message;
if (table && isGitHubCodeBlock(table.className)) {
const selectionStart = (e.target as any).selectionStart; // execCommand's insertText' triggers a 'change' event, hence we need not set respective state explicitly.
const selectionEnd = (e.target as any).selectionEnd; if (shouldApplyLinkMarkdown) {
const {formattedMessage, formattedCodeBlock} = formatGithubCodePaste({selectionStart, selectionEnd, message, clipboardData}); const formattedLink = formatMarkdownLinkMessage({selectionStart, selectionEnd, message, clipboardData});
const newCaretPosition = this.state.caretPosition + formattedCodeBlock.length; execCommandInsertText(formattedLink);
this.setMessageAndCaretPostion(formattedMessage, newCaretPosition); } else if (shouldApplyGithubCodeBlock) {
return; 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 = () => { 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) => { reactToLastMessage = (e: KeyboardEvent) => {
e.preventDefault(); e.preventDefault();

View File

@ -14,7 +14,7 @@ import * as Keyboard from 'utils/keyboard';
import { import {
formatGithubCodePaste, formatGithubCodePaste,
formatMarkdownMessage, formatMarkdownMessage,
getTable, getHtmlTable,
hasHtmlLink, hasHtmlLink,
isGitHubCodeBlock, isGitHubCodeBlock,
} from 'utils/paste'; } from 'utils/paste';
@ -163,7 +163,7 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
} }
const hasLinks = hasHtmlLink(clipboardData); const hasLinks = hasHtmlLink(clipboardData);
const table = getTable(clipboardData); const table = getHtmlTable(clipboardData);
if (!table && !hasLinks) { if (!table && !hasLinks) {
return; return;
} }
@ -178,7 +178,7 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
message = formattedMessage; message = formattedMessage;
newCaretPosition = selectionRange.start + formattedCodeBlock.length; newCaretPosition = selectionRange.start + formattedCodeBlock.length;
} else { } else {
message = formatMarkdownMessage(clipboardData, editText.trim(), newCaretPosition); message = formatMarkdownMessage(clipboardData, editText.trim(), newCaretPosition).formattedMessage;
newCaretPosition = message.length - (editText.length - newCaretPosition); newCaretPosition = message.length - (editText.length - newCaretPosition);
} }

View File

@ -18,7 +18,7 @@ import {
isIosChrome, isIosChrome,
isMobileApp, isMobileApp,
} from 'utils/user_agent'; } from 'utils/user_agent';
import {getTable} from 'utils/paste'; import {getHtmlTable} from 'utils/paste';
import { import {
clearFileInput, clearFileInput,
generateId, generateId,
@ -451,7 +451,7 @@ export class FileUpload extends PureComponent<Props, State> {
pasteUpload = (e: ClipboardEvent) => { pasteUpload = (e: ClipboardEvent) => {
const {formatMessage} = this.props.intl; const {formatMessage} = this.props.intl;
if (!e.clipboardData || !e.clipboardData.items || getTable(e.clipboardData)) { if (!e.clipboardData || !e.clipboardData.items || getHtmlTable(e.clipboardData)) {
return; return;
} }

View File

@ -20,7 +20,6 @@ import BasicSeparator from 'components/widgets/separator/basic-separator';
type Props = { type Props = {
focusOnMount: boolean; focusOnMount: boolean;
onHeightChange: (height: number, maxHeight: number) => void;
teammate?: UserProfile; teammate?: UserProfile;
threadId: string; threadId: string;
latestPostId: Post['id']; latestPostId: Post['id'];
@ -29,7 +28,6 @@ type Props = {
const CreateComment = forwardRef<HTMLDivElement, Props>(({ const CreateComment = forwardRef<HTMLDivElement, Props>(({
focusOnMount, focusOnMount,
onHeightChange,
teammate, teammate,
threadId, threadId,
latestPostId, latestPostId,
@ -97,7 +95,6 @@ const CreateComment = forwardRef<HTMLDivElement, Props>(({
focusOnMount={focusOnMount} focusOnMount={focusOnMount}
channelId={channel.id} channelId={channel.id}
latestPostId={latestPostId} latestPostId={latestPostId}
onHeightChange={onHeightChange}
rootDeleted={rootDeleted} rootDeleted={rootDeleted}
rootId={threadId} rootId={threadId}
isThreadView={isThreadView} isThreadView={isThreadView}

View File

@ -63,8 +63,6 @@ const innerStyles = {
paddingTop: '28px', paddingTop: '28px',
}; };
const CREATE_COMMENT_BUTTON_HEIGHT = 81;
const THREADING_TIME: typeof BASE_THREADING_TIME = { const THREADING_TIME: typeof BASE_THREADING_TIME = {
...BASE_THREADING_TIME, ...BASE_THREADING_TIME,
units: [ 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}) => { renderRow = ({data, itemId, style}: {data: any; itemId: any; style: any}) => {
const index = data.indexOf(itemId); const index = data.indexOf(itemId);
let className = ''; 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))} focusOnMount={!this.props.isThreadView && (this.state.userScrolledToBottom || (!this.state.userScrolled && this.getInitialPostIndex() === 0))}
isThreadView={this.props.isThreadView} isThreadView={this.props.isThreadView}
latestPostId={this.props.lastPost.id} latestPostId={this.props.lastPost.id}
onHeightChange={this.handleCreateCommentHeightChange}
ref={this.postCreateContainerRef} ref={this.postCreateContainerRef}
teammate={this.props.directTeammate} teammate={this.props.directTeammate}
threadId={this.props.selected.id} 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', { Object.defineProperty(document, 'queryCommandSupported', {
value: (cmd) => supportedCommands.includes(cmd), 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. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // 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 = { const validClipboardData: any = {
items: [1], 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', () => { test('returns false without html in the clipboard', () => {
const badClipboardData: any = { const badClipboardData: any = {
items: [1], items: [1],
types: ['text/plain'], types: ['text/plain'],
}; };
expect(getTable(badClipboardData)).toBe(null); expect(getHtmlTable(badClipboardData)).toBe(null);
}); });
test('returns false without table in the clipboard', () => { test('returns false without table in the clipboard', () => {
@ -30,19 +30,19 @@ describe('Paste.getTable', () => {
getData: () => '<p>There is no table here</p>', 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', () => { 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 |'; const markdownTable = '| test | test |\n| --- | --- |\n| test | test |';
test('returns a markdown table when valid html table provided', () => { 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', () => { 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', () => { 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', () => { test('returns a markdown table under a message when one is provided', () => {
const testMessage = 'test message'; 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', () => { test('returns a markdown formatted link when valid hyperlink provided', () => {
@ -85,11 +85,11 @@ describe('Paste.formatMarkdownMessage', () => {
}; };
const markdownLink = '[link text](https://test.domain)'; 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 = { const clipboardData: any = {
items: [], items: [],
types: ['text/plain', 'text/html'], types: ['text/plain', 'text/html'],
@ -147,3 +147,50 @@ describe('Paste.formatGithubCodePaste', () => {
expect(codeBlock).toBe(formattedCodeBlock); 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. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import TurndownService from 'turndown'; import turndownService from 'utils/turndown';
import {tables} from '@guyplusplus/turndown-plugin-gfm';
import {splitMessageBasedOnCaretPosition, splitMessageBasedOnTextSelection} from 'utils/post_utils'; import {splitMessageBasedOnCaretPosition, splitMessageBasedOnTextSelection} from 'utils/post_utils';
type FormatCodeOptions = { type FormatMarkdownParams = {
message: string; message: string;
clipboardData: DataTransfer; clipboardData: DataTransfer;
selectionStart: number | null; selectionStart: number | null;
selectionEnd: 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'); 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) { if (Array.from(clipboardData.types).indexOf('text/html') === -1) {
return null; return null;
} }
@ -28,7 +26,7 @@ export function getTable(clipboardData: DataTransfer): HTMLTableElement | null {
return null; return null;
} }
const table = parseTable(html); const table = parseHtmlTable(html);
if (!table) { if (!table) {
return null; 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')); 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 { export function isGitHubCodeBlock(tableClassName: string): boolean {
const result = (/\b(js|blob|diff)-./).test(tableClassName); const result = (/\b(js|blob|diff)-./).test(tableClassName);
return result; return result;
} }
export function isHttpProtocol(url: string): boolean { export function isTextUrl(clipboardData: DataTransfer): boolean {
return url.startsWith('http://'); const clipboardText = clipboardData.getData('text/plain');
return clipboardText.startsWith('http://') || clipboardText.startsWith('https://');
} }
export function isHttpsProtocol(url: string): boolean { function isTableWithoutHeaderRow(table: HTMLTableElement): boolean {
return url.startsWith('https://');
}
function isHeaderlessTable(table: HTMLTableElement): boolean {
return table.querySelectorAll('th').length === 0; 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'); const html = clipboardData.getData('text/html');
//TODO@michel: Instantiate turndown service in a central file instead let formattedMarkdown = turndownService.turndown(html).trim();
const service = new TurndownService({emDelimiter: '*'}).remove('style');
service.use(tables);
let markdownFormattedMessage = service.turndown(html).trim();
const table = getTable(clipboardData); const table = getHtmlTable(clipboardData);
if (table && isTableWithoutHeaderRow(table)) {
if (table && isHeaderlessTable(table)) { const newLineLimiter = '\n';
markdownFormattedMessage += '\n'; formattedMarkdown = `${formattedMarkdown}${newLineLimiter}`;
} }
let formattedMessage: string;
if (!message) { 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}`; return {formattedMessage, formattedMarkdown};
}
const newMessage = [message.slice(0, caretPosition) + '\n', markdownFormattedMessage, message.slice(caretPosition)];
return newMessage.join('\n');
} }
export function formatGithubCodePaste({message, clipboardData, selectionStart, selectionEnd}: FormatCodeOptions): {formattedMessage: string; formattedCodeBlock: string} { /**
const textSelected = selectionStart !== selectionEnd; * Format the incoming github code paste into a markdown code block.
const {firstPiece, lastPiece} = textSelected ? splitMessageBasedOnTextSelection(selectionStart ?? message.length, selectionEnd ?? message.length, message) : splitMessageBasedOnCaretPosition(selectionStart ?? message.length, message); * 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. // Add new lines if content exists before or after the cursor.
const requireStartLF = firstPiece === '' ? '' : '\n'; const requireStartLF = firstPiece === '' ? '' : '\n';
const requireEndLF = lastPiece === '' ? '' : '\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}`; const formattedMessage = `${firstPiece}${formattedCodeBlock}${lastPiece}`;
return {formattedMessage, formattedCodeBlock}; 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;