mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
MM-49368 : Pasting non plain text clears undo history of Textbox (#23535)
This commit is contained in:
parent
ba4dc1a91c
commit
b9cd2a9814
@ -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"> </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">></span> <span class="pl-c1">0</span>) {</td></tr><tr><td id="L3" class="blob-num js-line-number" data-line-number="3"> </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"> </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', () => {
|
||||
|
@ -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();
|
||||
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
@ -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"> </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">></span> <span class="pl-c1">0</span>) {</td></tr><tr><td id="L3" class="blob-num js-line-number" data-line-number="3"> </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"> </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);
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -29,7 +29,7 @@ Object.defineProperty(window, 'location', {
|
||||
},
|
||||
});
|
||||
|
||||
const supportedCommands = ['copy'];
|
||||
const supportedCommands = ['copy', 'insertText'];
|
||||
|
||||
Object.defineProperty(document, 'queryCommandSupported', {
|
||||
value: (cmd) => supportedCommands.includes(cmd),
|
||||
|
10
webapp/channels/src/utils/exec_commands.ts
Normal file
10
webapp/channels/src/utils/exec_commands.ts
Normal 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);
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
10
webapp/channels/src/utils/turndown.ts
Normal file
10
webapp/channels/src/utils/turndown.ts
Normal 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;
|
Loading…
Reference in New Issue
Block a user