Unify handlePaste and handlekeydown (#24511)

* Unify paste and handlekeydown

* Fix snapshots

* Address feedback

* Properly fix MM-54178 and avoid opening the reaction from the edit post textbox
Этот коммит содержится в:
Daniel Espino García
2023-10-26 18:41:43 +02:00
коммит произвёл GitHub
родитель c1eb40ae05
Коммит 756765b9b4
16 изменённых файлов: 850 добавлений и 1498 удалений

Просмотреть файл

@@ -10,7 +10,9 @@ exports[`components/AdvancedCreateComment should match snapshot when cannot post
badConnection={false}
canPost={false}
canUploadFiles={true}
caretPosition={12}
channelId="g6139tbospd18cmxroesdk3kkc"
ctrlSend={false}
currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa"
draft={
Object {
@@ -88,7 +90,6 @@ exports[`components/AdvancedCreateComment should match snapshot when cannot post
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -97,9 +98,13 @@ exports[`components/AdvancedCreateComment should match snapshot when cannot post
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="RHS_COMMENT"
maxPostSize={4000}
message="Test message"
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
removePreview={[Function]}
@@ -130,7 +135,9 @@ exports[`components/AdvancedCreateComment should match snapshot, comment with me
badConnection={false}
canPost={true}
canUploadFiles={true}
caretPosition={12}
channelId="g6139tbospd18cmxroesdk3kkc"
ctrlSend={true}
currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa"
draft={
Object {
@@ -159,7 +166,6 @@ exports[`components/AdvancedCreateComment should match snapshot, comment with me
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -168,9 +174,13 @@ exports[`components/AdvancedCreateComment should match snapshot, comment with me
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="RHS_COMMENT"
maxPostSize={4000}
message="Test message"
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
removePreview={[Function]}
@@ -202,7 +212,9 @@ exports[`components/AdvancedCreateComment should match snapshot, emoji picker di
badConnection={false}
canPost={true}
canUploadFiles={true}
caretPosition={12}
channelId="g6139tbospd18cmxroesdk3kkc"
ctrlSend={false}
currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa"
draft={
Object {
@@ -280,7 +292,6 @@ exports[`components/AdvancedCreateComment should match snapshot, emoji picker di
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -289,9 +300,13 @@ exports[`components/AdvancedCreateComment should match snapshot, emoji picker di
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="RHS_COMMENT"
maxPostSize={4000}
message="Test message"
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
removePreview={[Function]}
@@ -322,7 +337,9 @@ exports[`components/AdvancedCreateComment should match snapshot, empty comment 1
badConnection={false}
canPost={true}
canUploadFiles={true}
caretPosition={0}
channelId="g6139tbospd18cmxroesdk3kkc"
ctrlSend={true}
currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa"
draft={
Object {
@@ -351,7 +368,6 @@ exports[`components/AdvancedCreateComment should match snapshot, empty comment 1
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -360,9 +376,13 @@ exports[`components/AdvancedCreateComment should match snapshot, empty comment 1
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="RHS_COMMENT"
maxPostSize={4000}
message=""
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
removePreview={[Function]}
@@ -394,7 +414,9 @@ exports[`components/AdvancedCreateComment should match snapshot, non-empty messa
badConnection={false}
canPost={true}
canUploadFiles={true}
caretPosition={12}
channelId="g6139tbospd18cmxroesdk3kkc"
ctrlSend={false}
currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa"
draft={
Object {
@@ -472,7 +494,6 @@ exports[`components/AdvancedCreateComment should match snapshot, non-empty messa
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -481,9 +502,13 @@ exports[`components/AdvancedCreateComment should match snapshot, non-empty messa
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="RHS_COMMENT"
maxPostSize={4000}
message="Test message"
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
removePreview={[Function]}

Просмотреть файл

@@ -11,12 +11,9 @@ import type {ActionResult} from 'mattermost-redux/types/actions';
import type {Props} from 'components/advanced_create_comment/advanced_create_comment';
import AdvancedCreateComment from 'components/advanced_create_comment/advanced_create_comment';
import AdvanceTextEditor from 'components/advanced_text_editor/advanced_text_editor';
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 {TestHelper} from 'utils/test_helper';
import type {PostDraft} from 'types/store/draft';
@@ -65,8 +62,8 @@ describe('components/AdvancedCreateComment', () => {
updateCommentDraftWithRootId: jest.fn(),
onSubmit: jest.fn(),
onResetHistoryIndex: jest.fn(),
onMoveHistoryIndexBack: jest.fn(),
onMoveHistoryIndexForward: jest.fn(),
moveHistoryIndexBack: jest.fn(),
moveHistoryIndexForward: jest.fn(),
onEditLatestPost: jest.fn(),
resetCreatePostRequest: jest.fn(),
setShowPreview: jest.fn(),
@@ -1208,99 +1205,6 @@ describe('components/AdvancedCreateComment', () => {
expect(instance.focusTextbox).toHaveBeenCalledTimes(1);
});
test('should call functions on handleKeyDown', () => {
const onMoveHistoryIndexBack = jest.fn();
const onMoveHistoryIndexForward = jest.fn();
const onEditLatestPost = jest.fn().
mockImplementationOnce(() => ({data: true})).
mockImplementationOnce(() => ({data: false}));
const wrapper = shallow<AdvancedCreateComment>(
<AdvancedCreateComment
{...baseProps}
ctrlSend={true}
onMoveHistoryIndexBack={onMoveHistoryIndexBack}
onMoveHistoryIndexForward={onMoveHistoryIndexForward}
onEditLatestPost={onEditLatestPost}
/>,
);
const instance = wrapper.instance();
instance.commentMsgKeyPress = jest.fn();
instance.focusTextbox = jest.fn();
const blur = jest.fn();
const mockImpl = () => {
return {
setSelectionRange: jest.fn(),
getBoundingClientRect: jest.fn(mockTop),
blur: jest.fn(),
focus: jest.fn(),
};
};
const mockTop = () => {
return document.createElement('div');
};
(instance as any).textboxRef.current = {blur, focus, getInputBox: jest.fn(mockImpl)};
const mockTarget = {
selectionStart: 0,
selectionEnd: 0,
value: 'brown\nfox jumps over lazy dog',
};
const commentMsgKey: any = {
preventDefault: jest.fn(),
ctrlKey: true,
key: Constants.KeyCodes.ENTER[0],
keyCode: Constants.KeyCodes.ENTER[1],
target: mockTarget,
};
instance.handleKeyDown(commentMsgKey);
expect(instance.commentMsgKeyPress).toHaveBeenCalledTimes(1);
const upKey: any = {
preventDefault: jest.fn(),
ctrlKey: true,
key: Constants.KeyCodes.UP[0],
keyCode: Constants.KeyCodes.UP[1],
target: mockTarget,
};
instance.handleKeyDown(upKey);
expect(upKey.preventDefault).toHaveBeenCalledTimes(1);
expect(onMoveHistoryIndexBack).toHaveBeenCalledTimes(1);
const downKey = {
preventDefault: jest.fn(),
ctrlKey: true,
key: Constants.KeyCodes.DOWN[0],
keyCode: Constants.KeyCodes.DOWN[1],
target: mockTarget,
};
instance.handleKeyDown(downKey as any);
expect(downKey.preventDefault).toHaveBeenCalledTimes(1);
expect(onMoveHistoryIndexForward).toHaveBeenCalledTimes(1);
wrapper.setState({draft: emptyDraft});
const upKeyForEdit: any = {
preventDefault: jest.fn(),
ctrlKey: false,
key: Constants.KeyCodes.UP[0],
keyCode: Constants.KeyCodes.UP[1],
target: mockTarget,
};
instance.handleKeyDown(upKeyForEdit);
expect(upKeyForEdit.preventDefault).toHaveBeenCalledTimes(1);
expect(onEditLatestPost).toHaveBeenCalledTimes(1);
expect(blur).toHaveBeenCalledTimes(1);
instance.handleKeyDown(upKeyForEdit);
expect(upKeyForEdit.preventDefault).toHaveBeenCalledTimes(2);
expect(onEditLatestPost).toHaveBeenCalledTimes(2);
expect(instance.focusTextbox).toHaveBeenCalledTimes(1);
expect(instance.focusTextbox).toHaveBeenCalledWith(true);
});
test('should the RHS thread scroll to bottom one time after mount when props.draft.message is not empty', () => {
const draft: PostDraft = emptyDraft;
const scrollToBottom = jest.fn();
@@ -1348,181 +1252,6 @@ describe('components/AdvancedCreateComment', () => {
expect(scrollToBottom).toBeCalledTimes(2);
});
test('should be able to format a pasted markdown table', () => {
const draft: PostDraft = emptyDraft;
const wrapper = shallow<AdvancedCreateComment>(
<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() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()};
const event: any = {
target: {
id: 'reply_textbox',
},
preventDefault: jest.fn(),
clipboardData: {
items: [1],
types: ['text/html'],
getData: () => {
return '<table><tr><th>test</th><th>test</th></tr><tr><td>test</td><td>test</td></tr></table>';
},
},
};
const markdownTable = '| test | test |\n| --- | --- |\n| test | test |';
wrapper.instance().pasteHandler(event);
expect(execCommandInsertText).toHaveBeenCalledWith(markdownTable);
});
test('should be able to format a pasted markdown table without headers', () => {
const draft: PostDraft = emptyDraft;
const wrapper = shallow<AdvancedCreateComment>(
<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() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()};
const event: any = {
target: {
id: 'reply_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';
wrapper.instance().pasteHandler(event);
expect(execCommandInsertText).toHaveBeenCalledWith(markdownTable);
});
test('should be able to format a pasted hyperlink', () => {
const draft: PostDraft = emptyDraft;
const wrapper = shallow<AdvancedCreateComment>(
<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() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()};
const event: any = {
target: {
id: 'reply_textbox',
},
preventDefault: jest.fn(),
clipboardData: {
items: [1],
types: ['text/html'],
getData: () => {
return '<a href="https://test.domain">link text</a>';
},
},
};
const markdownLink = '[link text](https://test.domain)';
wrapper.instance().pasteHandler(event);
expect(execCommandInsertText).toHaveBeenCalledWith(markdownLink);
});
test('should be able to format a github codeblock (pasted as a table)', () => {
const draft: PostDraft = emptyDraft;
const wrapper = shallow<AdvancedCreateComment>(
<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() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()};
const event: any = {
target: {
id: 'reply_textbox',
},
preventDefault: jest.fn(),
clipboardData: {
items: [1],
types: ['text/plain', 'text/html'],
getData: (type: any) => {
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 = "```\n// a javascript codeblock example\nif (1 > 0) {\n return 'condition is true';\n}\n```";
wrapper.instance().pasteHandler(event);
expect(execCommandInsertText).toHaveBeenCalledWith(codeBlockMarkdown);
});
test('should show preview and edit mode, and return focus on preview disable', () => {
const wrapper = shallow<AdvancedCreateComment>(
<AdvancedCreateComment {...baseProps}/>,
@@ -1552,73 +1281,4 @@ describe('components/AdvancedCreateComment', () => {
ctrlSend={true}
/>
), (instance: any) => instance.state().draft.message, false);
testComponentForMarkdownHotkeys(
(value: any) => (
<AdvancedCreateComment
{...baseProps}
draft={{
...baseProps.draft,
message: value,
}}
ctrlSend={true}
/>
),
(wrapper: any, setSelectionRangeFn: any) => {
const mockTop = () => {
return document.createElement('div');
};
wrapper.instance().textboxRef = {
current: {
getInputBox: jest.fn(() => {
return {
focus: jest.fn(),
getBoundingClientRect: jest.fn(mockTop),
setSelectionRange: setSelectionRangeFn,
};
}),
},
};
},
(instance: any) => instance.find(AdvanceTextEditor),
(instance: any) => instance.state().draft.message,
false,
'reply_textbox',
);
it('should blur when ESCAPE is pressed', () => {
const wrapper = shallow<AdvancedCreateComment>(
<AdvancedCreateComment
{...baseProps}
/>,
);
const instance = wrapper.instance();
const blur = jest.fn();
const mockImpl = () => {
return {
blur: jest.fn(),
focus: jest.fn(),
};
};
(instance as any).textboxRef.current = {blur, getInputBox: jest.fn(mockImpl)};
const mockTarget = {
selectionStart: 0,
selectionEnd: 0,
value: 'brown\nfox jumps over lazy dog',
};
const commentEscapeKey = {
preventDefault: jest.fn(),
ctrlKey: true,
key: Constants.KeyCodes.ESCAPE[0],
keyCode: Constants.KeyCodes.ESCAPE[1],
target: mockTarget,
};
instance.handleKeyDown(commentEscapeKey as any);
expect(blur).toHaveBeenCalledTimes(1);
});
});

Просмотреть файл

@@ -3,7 +3,6 @@
/* eslint-disable max-lines */
import {isNil} from 'lodash';
import React from 'react';
import type {ChannelMemberCountsByGroup} from '@mattermost/types/channels';
@@ -14,6 +13,7 @@ import {GroupSource} from '@mattermost/types/groups';
import type {Group} from '@mattermost/types/groups';
import type {PreferenceType} from '@mattermost/types/preferences';
import {Posts} from 'mattermost-redux/constants';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {sortFileInfos} from 'mattermost-redux/utils/file_utils';
@@ -28,22 +28,11 @@ import PostDeletedModal from 'components/post_deleted_modal';
import type {TextboxClass, TextboxElement} from 'components/textbox';
import Constants, {AdvancedTextEditor as AdvancedTextEditorConst, Locations, ModalIdentifiers, Preferences} from 'utils/constants';
import {execCommandInsertText} from 'utils/exec_commands';
import * as Keyboard from 'utils/keyboard';
import {
applyMarkdown,
} from 'utils/markdown/apply_markdown';
import type {
ApplyMarkdownOptions} from 'utils/markdown/apply_markdown';
import {
getHtmlTable,
hasHtmlLink,
formatMarkdownMessage,
isGitHubCodeBlock,
formatGithubCodePaste,
isTextUrl,
formatMarkdownLinkMessage,
} from 'utils/paste';
import {
specialMentionsInText,
postMessageOnKeyPress,
@@ -52,7 +41,6 @@ import {
splitMessageBasedOnCaretPosition,
groupsMentionedInText,
mentionsMinusSpecialMentionsInText,
isWithinCodeBlock,
} from 'utils/post_utils';
import * as UserAgent from 'utils/user_agent';
import * as Utils from 'utils/utils';
@@ -61,8 +49,6 @@ import type {ModalData} from 'types/actions';
import type {PostDraft} from 'types/store/draft';
import type {PluginComponent} from 'types/store/plugins';
const KeyCodes = Constants.KeyCodes;
export type Props = {
currentTeamId: string;
@@ -130,10 +116,10 @@ export type Props = {
onResetHistoryIndex: () => void;
// Called when navigating back through comment message history
onMoveHistoryIndexBack: () => void;
moveHistoryIndexBack: (index: string) => Promise<void>;
// Called when navigating forward through comment message history
onMoveHistoryIndexForward: () => void;
moveHistoryIndexForward: (index: string) => Promise<void>;
// Called to initiate editing the user's latest post
onEditLatestPost: () => ActionResult;
@@ -206,11 +192,11 @@ type State = {
uploadsProgressPercent: {[clientID: string]: FilePreviewInfo};
renderScrollbar: boolean;
scrollbarWidth: number;
draft?: PostDraft;
draft: PostDraft;
rootId?: string;
messageInHistory?: string;
createPostErrorId?: string;
caretPosition?: number;
caretPosition: number;
postError?: React.ReactNode;
errorClass: string | null;
serverError: (ServerError & {submittedMessage?: string}) | null;
@@ -248,7 +234,6 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
createPostErrorId: props.createPostErrorId,
rootId: props.rootId,
messageInHistory: props.messageInHistory,
draft: state.draft || {...props.draft, caretPosition: props.draft.message.length, uploadsInProgress: []},
};
const rootChanged = props.rootId !== state.rootId || props.draft.rootId !== state.draft?.rootId;
@@ -278,7 +263,8 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
serverError: null,
showFormat: false,
isFormattingBarHidden: props.isFormattingBarHidden,
caretPosition: props.draft.caretPosition,
caretPosition: props.draft.message.length,
draft: {...props.draft, uploadsInProgress: []},
};
this.textboxRef = React.createRef();
@@ -295,7 +281,6 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
this.focusTextbox();
}
document.addEventListener('paste', this.pasteHandler);
document.addEventListener('keydown', this.focusTextboxIfNecessary);
window.addEventListener('beforeunload', this.saveDraftWithShow);
this.getChannelMemberCountsByGroup();
@@ -310,7 +295,6 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
componentWillUnmount() {
this.props.resetCreatePostRequest?.();
document.removeEventListener('paste', this.pasteHandler);
document.removeEventListener('keydown', this.focusTextboxIfNecessary);
window.removeEventListener('beforeunload', this.saveDraftWithShow);
this.saveDraftOnUnmount();
@@ -351,6 +335,26 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
}
}
fillMessageFromHistory() {
const lastMessage = this.props.messageInHistory;
this.setState((prev) => ({
draft: {
...prev.draft,
message: lastMessage || '',
},
}));
}
loadPrevMessage = (e: React.KeyboardEvent) => {
e.preventDefault();
this.props.moveHistoryIndexBack(Posts.MESSAGE_TYPES.COMMENT).then(() => this.fillMessageFromHistory());
};
loadNextMessage = (e: React.KeyboardEvent) => {
e.preventDefault();
this.props.moveHistoryIndexForward(Posts.MESSAGE_TYPES.COMMENT).then(() => this.fillMessageFromHistory());
};
getChannelMemberCountsByGroup = () => {
const {useLDAPGroupMentions, useCustomGroupMentions, channelId, isTimezoneEnabled, searchAssociatedGroupsForReference, getChannelMemberCountsByGroup, draft, currentTeamId} = this.props;
@@ -438,43 +442,6 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
});
};
pasteHandler = (event: ClipboardEvent) => {
const {clipboardData, target} = event;
if (!clipboardData || !clipboardData.items || !target || (target as TextboxElement)?.id !== 'reply_textbox') {
return;
}
const {selectionStart, selectionEnd} = target as TextboxElement;
const hasSelection = !isNil(selectionStart) && !isNil(selectionEnd) && selectionStart < selectionEnd;
const hasTextUrl = isTextUrl(clipboardData);
const hasHTMLLinks = !this.isNonFormattedPaste && hasHtmlLink(clipboardData);
const htmlTable = getHtmlTable(clipboardData);
const shouldApplyLinkMarkdown = hasSelection && hasTextUrl;
const shouldApplyGithubCodeBlock = htmlTable && isGitHubCodeBlock(htmlTable.className);
if (!htmlTable && !hasHTMLLinks && !shouldApplyLinkMarkdown) {
return;
}
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) {
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);
}
};
handleNotifyAllConfirmation = () => {
this.doSubmit();
};
@@ -841,188 +808,6 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
});
};
handleKeyDown = (e: React.KeyboardEvent<TextboxElement>) => {
const ctrlOrMetaKeyPressed = e.ctrlKey || e.metaKey;
const lastMessageReactionKeyCombo = ctrlOrMetaKeyPressed && e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.BACK_SLASH);
const ctrlKeyCombo = Keyboard.cmdOrCtrlPressed(e) && !e.altKey && !e.shiftKey;
const ctrlAltCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.altKey;
const shiftAltCombo = !Keyboard.cmdOrCtrlPressed(e) && e.shiftKey && e.altKey;
// fix for FF not capturing the paste without formatting event when using ctrl|cmd + shift + v
if (e.key === KeyCodes.V[0] && ctrlOrMetaKeyPressed) {
if (e.shiftKey) {
this.isNonFormattedPaste = true;
this.timeoutId = window.setTimeout(() => {
this.isNonFormattedPaste = false;
}, 250);
}
}
// listen for line break key combo and insert new line character
if (Utils.isUnhandledLineBreakKeyCombo(e)) {
this.setState({
draft: {
...this.state.draft!,
message: Utils.insertLineBreakFromKeyEvent(e as React.KeyboardEvent<HTMLTextAreaElement>),
},
});
return;
}
if (
(this.props.ctrlSend || this.props.codeBlockOnCtrlEnter) &&
Keyboard.isKeyPressed(e, KeyCodes.ENTER) &&
(e.ctrlKey || e.metaKey)
) {
this.setShowPreview(false);
this.commentMsgKeyPress(e);
return;
}
const draft = this.state.draft!;
const {message} = draft;
const {caretPosition} = this.state;
const caretIsWithinCodeBlock = caretPosition && isWithinCodeBlock(message, caretPosition);
if (Keyboard.isKeyPressed(e, KeyCodes.ESCAPE)) {
this.textboxRef.current?.blur();
}
if (
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!e.shiftKey &&
Keyboard.isKeyPressed(e, KeyCodes.UP) &&
message === ''
) {
e.preventDefault();
if (this.textboxRef.current) {
this.textboxRef.current.blur();
}
const {data: canEditNow} = this.props.onEditLatestPost();
if (!canEditNow) {
this.focusTextbox(true);
}
}
const {
selectionStart,
selectionEnd,
value,
} = e.target as TextboxElement;
if (ctrlKeyCombo && !caretIsWithinCodeBlock) {
if (Keyboard.isKeyPressed(e, KeyCodes.UP)) {
e.preventDefault();
this.props.onMoveHistoryIndexBack();
} else if (Keyboard.isKeyPressed(e, KeyCodes.DOWN)) {
e.preventDefault();
this.props.onMoveHistoryIndexForward();
} else if (Keyboard.isKeyPressed(e, KeyCodes.B)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
markdownMode: 'bold',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.I)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
markdownMode: 'italic',
selectionStart,
selectionEnd,
message: value,
});
} else if (Utils.isTextSelectedInPostOrReply(e) && Keyboard.isKeyPressed(e, KeyCodes.K)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
markdownMode: 'link',
selectionStart,
selectionEnd,
message: value,
});
}
} else if (ctrlAltCombo && !caretIsWithinCodeBlock) {
if (Keyboard.isKeyPressed(e, KeyCodes.K)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
markdownMode: 'link',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.C)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
markdownMode: 'code',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.E)) {
e.stopPropagation();
e.preventDefault();
this.toggleEmojiPicker();
} else if (Keyboard.isKeyPressed(e, KeyCodes.T)) {
e.stopPropagation();
e.preventDefault();
this.toggleAdvanceTextEditor();
} else if (Keyboard.isKeyPressed(e, KeyCodes.P) && draft.message.length) {
e.stopPropagation();
e.preventDefault();
this.setShowPreview(!this.props.shouldShowPreview);
}
} else if (shiftAltCombo && !caretIsWithinCodeBlock) {
if (Keyboard.isKeyPressed(e, KeyCodes.X)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
markdownMode: 'strike',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.SEVEN)) {
e.preventDefault();
this.applyMarkdown({
markdownMode: 'ol',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.EIGHT)) {
e.preventDefault();
this.applyMarkdown({
markdownMode: 'ul',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.NINE)) {
e.preventDefault();
this.applyMarkdown({
markdownMode: 'quote',
selectionStart,
selectionEnd,
message: value,
});
}
}
if (lastMessageReactionKeyCombo) {
this.reactToLastMessage(e);
}
};
applyMarkdown = (options: ApplyMarkdownOptions) => {
if (this.props.shouldShowPreview) {
return;
@@ -1221,6 +1006,25 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
this.lastBlurAt = Date.now();
};
handleEditLatestPost = () => {
const {data: canEditNow} = this.props.onEditLatestPost();
if (!canEditNow) {
this.focusTextbox(true);
}
};
onMessageChange = (message: string, callback?: (() => void) | undefined) => {
const draft = this.state.draft;
const modifiedDraft = {
...draft,
message,
};
this.handleDraftChange(modifiedDraft);
this.setState({
draft: modifiedDraft,
}, callback);
};
render() {
const draft = this.state.draft!;
@@ -1295,7 +1099,6 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
handlePostError={this.handlePostError}
emitTypingEvent={this.emitTypingEvent}
handleMouseUpKeyUp={this.handleMouseUpKeyUp}
handleKeyDown={this.handleKeyDown}
postMsgKeyPress={this.commentMsgKeyPress}
handleChange={this.handleChange}
toggleEmojiPicker={this.toggleEmojiPicker}
@@ -1312,6 +1115,13 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
fileUploadRef={this.fileUploadRef}
isThreadView={this.props.isThreadView}
additionalControls={pluginItems.filter(Boolean)}
codeBlockOnCtrlEnter={this.props.codeBlockOnCtrlEnter}
ctrlSend={this.props.ctrlSend}
loadNextMessage={this.loadNextMessage}
loadPrevMessage={this.loadPrevMessage}
onEditLatestPost={this.handleEditLatestPost}
onMessageChange={this.onMessageChange}
caretPosition={this.state.caretPosition}
placeholder={this.props.placeholder}
/>
</form>

Просмотреть файл

@@ -8,7 +8,7 @@ import type {ActionCreatorsMapObject, Dispatch} from 'redux';
import type {PreferenceType} from '@mattermost/types/preferences';
import {getChannelTimezones, getChannelMemberCountsByGroup} from 'mattermost-redux/actions/channels';
import {resetCreatePostRequest, resetHistoryIndex} from 'mattermost-redux/actions/posts';
import {moveHistoryIndexBack, moveHistoryIndexForward, resetCreatePostRequest, resetHistoryIndex} from 'mattermost-redux/actions/posts';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {Permissions, Preferences, Posts} from 'mattermost-redux/constants';
import {getAllChannelStats, getChannelMemberCountsByGroup as selectChannelMemberCountsByGroup} from 'mattermost-redux/selectors/entities/channels';
@@ -25,7 +25,6 @@ import {emitShortcutReactToLastPostFrom} from 'actions/post_actions';
import {
clearCommentDraftUploads,
updateCommentDraft,
makeOnMoveHistoryIndex,
makeOnSubmit,
makeOnEditLatestPost,
} from 'actions/views/create_comment';
@@ -132,8 +131,8 @@ type Actions = {
updateCommentDraftWithRootId: (rootID: string, draft: PostDraft, save?: boolean) => void;
onSubmit: (draft: PostDraft, options: {ignoreSlash: boolean}) => void;
onResetHistoryIndex: () => void;
onMoveHistoryIndexBack: () => void;
onMoveHistoryIndexForward: () => void;
moveHistoryIndexBack: (index: string) => Promise<void>;
moveHistoryIndexForward: (index: string) => Promise<void>;
onEditLatestPost: () => ActionResult;
resetCreatePostRequest: () => void;
getChannelTimezones: (channelId: string) => Promise<ActionResult>;
@@ -152,14 +151,6 @@ function makeMapDispatchToProps() {
draft: PostDraft,
options: {ignoreSlash: boolean},
) => (dispatch: DispatchFunc, getState: () => GlobalState) => Promise<ActionResult | ActionResult[]> | ActionResult;
let onMoveHistoryIndexBack: () => (
dispatch: DispatchFunc,
getState: () => GlobalState,
) => Promise<ActionResult | ActionResult[]> | ActionResult;
let onMoveHistoryIndexForward: () => (
dispatch: DispatchFunc,
getState: () => GlobalState,
) => Promise<ActionResult | ActionResult[]> | ActionResult;
let onEditLatestPost: () => ActionFunc;
function onResetHistoryIndex() {
@@ -173,8 +164,6 @@ function makeMapDispatchToProps() {
return (dispatch: Dispatch, ownProps: OwnProps) => {
if (rootId !== ownProps.rootId) {
onUpdateCommentDraft = makeOnUpdateCommentDraft(ownProps.rootId, ownProps.channelId);
onMoveHistoryIndexBack = makeOnMoveHistoryIndex(ownProps.rootId, -1);
onMoveHistoryIndexForward = makeOnMoveHistoryIndex(ownProps.rootId, 1);
}
if (channelId !== ownProps.channelId) {
@@ -200,8 +189,8 @@ function makeMapDispatchToProps() {
updateCommentDraftWithRootId,
onSubmit,
onResetHistoryIndex,
onMoveHistoryIndexBack,
onMoveHistoryIndexForward,
moveHistoryIndexBack,
moveHistoryIndexForward,
onEditLatestPost,
resetCreatePostRequest,
getChannelTimezones,

Просмотреть файл

@@ -13,7 +13,9 @@ exports[`components/advanced_create_post Show tutorial 1`] = `
badConnection={false}
canPost={true}
canUploadFiles={false}
caretPosition={0}
channelId="owsyt8n43jfxjpzh9np93mx1wa"
ctrlSend={false}
currentChannel={
Object {
"create_at": 0,
@@ -62,7 +64,6 @@ exports[`components/advanced_create_post Show tutorial 1`] = `
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -71,13 +72,18 @@ exports[`components/advanced_create_post Show tutorial 1`] = `
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="CENTER"
maxPostSize={4000}
message=""
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
prefillMessage={[Function]}
removePreview={[Function]}
replyToLastPost={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
@@ -109,7 +115,9 @@ exports[`components/advanced_create_post should match snapshot for center textbo
badConnection={false}
canPost={true}
canUploadFiles={false}
caretPosition={0}
channelId="owsyt8n43jfxjpzh9np93mx1wa"
ctrlSend={false}
currentChannel={
Object {
"create_at": 0,
@@ -158,7 +166,6 @@ exports[`components/advanced_create_post should match snapshot for center textbo
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -167,13 +174,18 @@ exports[`components/advanced_create_post should match snapshot for center textbo
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="CENTER"
maxPostSize={4000}
message=""
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
prefillMessage={[Function]}
removePreview={[Function]}
replyToLastPost={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
@@ -205,7 +217,9 @@ exports[`components/advanced_create_post should match snapshot when cannot post
badConnection={false}
canPost={false}
canUploadFiles={false}
caretPosition={0}
channelId="owsyt8n43jfxjpzh9np93mx1wa"
ctrlSend={false}
currentChannel={
Object {
"create_at": 0,
@@ -254,7 +268,6 @@ exports[`components/advanced_create_post should match snapshot when cannot post
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -263,13 +276,18 @@ exports[`components/advanced_create_post should match snapshot when cannot post
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="CENTER"
maxPostSize={4000}
message=""
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
prefillMessage={[Function]}
removePreview={[Function]}
replyToLastPost={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
@@ -301,7 +319,9 @@ exports[`components/advanced_create_post should match snapshot when file upload
badConnection={false}
canPost={true}
canUploadFiles={false}
caretPosition={0}
channelId="owsyt8n43jfxjpzh9np93mx1wa"
ctrlSend={false}
currentChannel={
Object {
"create_at": 0,
@@ -350,7 +370,6 @@ exports[`components/advanced_create_post should match snapshot when file upload
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -359,13 +378,18 @@ exports[`components/advanced_create_post should match snapshot when file upload
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="CENTER"
maxPostSize={4000}
message=""
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
prefillMessage={[Function]}
removePreview={[Function]}
replyToLastPost={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
@@ -397,7 +421,9 @@ exports[`components/advanced_create_post should match snapshot, can post; previe
badConnection={false}
canPost={true}
canUploadFiles={false}
caretPosition={0}
channelId="owsyt8n43jfxjpzh9np93mx1wa"
ctrlSend={false}
currentChannel={
Object {
"create_at": 0,
@@ -446,7 +472,6 @@ exports[`components/advanced_create_post should match snapshot, can post; previe
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -455,13 +480,18 @@ exports[`components/advanced_create_post should match snapshot, can post; previe
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="CENTER"
maxPostSize={4000}
message=""
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
prefillMessage={[Function]}
removePreview={[Function]}
replyToLastPost={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
@@ -493,7 +523,9 @@ exports[`components/advanced_create_post should match snapshot, can post; previe
badConnection={false}
canPost={true}
canUploadFiles={false}
caretPosition={0}
channelId="owsyt8n43jfxjpzh9np93mx1wa"
ctrlSend={false}
currentChannel={
Object {
"create_at": 0,
@@ -542,7 +574,6 @@ exports[`components/advanced_create_post should match snapshot, can post; previe
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -551,13 +582,18 @@ exports[`components/advanced_create_post should match snapshot, can post; previe
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="CENTER"
maxPostSize={4000}
message=""
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
prefillMessage={[Function]}
removePreview={[Function]}
replyToLastPost={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
@@ -589,7 +625,9 @@ exports[`components/advanced_create_post should match snapshot, cannot post; pre
badConnection={false}
canPost={false}
canUploadFiles={false}
caretPosition={0}
channelId="owsyt8n43jfxjpzh9np93mx1wa"
ctrlSend={false}
currentChannel={
Object {
"create_at": 0,
@@ -638,7 +676,6 @@ exports[`components/advanced_create_post should match snapshot, cannot post; pre
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -647,13 +684,18 @@ exports[`components/advanced_create_post should match snapshot, cannot post; pre
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="CENTER"
maxPostSize={4000}
message=""
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
prefillMessage={[Function]}
removePreview={[Function]}
replyToLastPost={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
@@ -685,7 +727,9 @@ exports[`components/advanced_create_post should match snapshot, cannot post; pre
badConnection={false}
canPost={false}
canUploadFiles={false}
caretPosition={0}
channelId="owsyt8n43jfxjpzh9np93mx1wa"
ctrlSend={false}
currentChannel={
Object {
"create_at": 0,
@@ -734,7 +778,6 @@ exports[`components/advanced_create_post should match snapshot, cannot post; pre
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -743,13 +786,18 @@ exports[`components/advanced_create_post should match snapshot, cannot post; pre
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="CENTER"
maxPostSize={4000}
message=""
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
prefillMessage={[Function]}
removePreview={[Function]}
replyToLastPost={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
@@ -781,7 +829,9 @@ exports[`components/advanced_create_post should match snapshot, init 1`] = `
badConnection={false}
canPost={true}
canUploadFiles={false}
caretPosition={0}
channelId="owsyt8n43jfxjpzh9np93mx1wa"
ctrlSend={false}
currentChannel={
Object {
"create_at": 0,
@@ -830,7 +880,6 @@ exports[`components/advanced_create_post should match snapshot, init 1`] = `
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -839,13 +888,18 @@ exports[`components/advanced_create_post should match snapshot, init 1`] = `
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="CENTER"
maxPostSize={4000}
message=""
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
prefillMessage={[Function]}
removePreview={[Function]}
replyToLastPost={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
@@ -877,7 +931,9 @@ exports[`components/advanced_create_post should match snapshot, post priority di
badConnection={false}
canPost={true}
canUploadFiles={false}
caretPosition={0}
channelId="owsyt8n43jfxjpzh9np93mx1wa"
ctrlSend={false}
currentChannel={
Object {
"create_at": 0,
@@ -931,7 +987,6 @@ exports[`components/advanced_create_post should match snapshot, post priority di
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -940,13 +995,18 @@ exports[`components/advanced_create_post should match snapshot, post priority di
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="CENTER"
maxPostSize={4000}
message=""
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
prefillMessage={[Function]}
removePreview={[Function]}
replyToLastPost={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
@@ -986,7 +1046,9 @@ exports[`components/advanced_create_post should match snapshot, post priority en
badConnection={false}
canPost={true}
canUploadFiles={false}
caretPosition={0}
channelId="owsyt8n43jfxjpzh9np93mx1wa"
ctrlSend={false}
currentChannel={
Object {
"create_at": 0,
@@ -1035,7 +1097,6 @@ exports[`components/advanced_create_post should match snapshot, post priority en
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -1044,13 +1105,18 @@ exports[`components/advanced_create_post should match snapshot, post priority en
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="CENTER"
maxPostSize={4000}
message=""
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
prefillMessage={[Function]}
removePreview={[Function]}
replyToLastPost={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
@@ -1095,7 +1161,9 @@ exports[`components/advanced_create_post should match snapshot, post priority en
badConnection={false}
canPost={true}
canUploadFiles={false}
caretPosition={0}
channelId="owsyt8n43jfxjpzh9np93mx1wa"
ctrlSend={false}
currentChannel={
Object {
"create_at": 0,
@@ -1149,7 +1217,6 @@ exports[`components/advanced_create_post should match snapshot, post priority en
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleKeyDown={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
@@ -1173,13 +1240,18 @@ exports[`components/advanced_create_post should match snapshot, post priority en
}
/>
}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="CENTER"
maxPostSize={4000}
message=""
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
prefillMessage={[Function]}
removePreview={[Function]}
replyToLastPost={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}

Просмотреть файл

@@ -9,23 +9,16 @@ import type {CommandArgs} from '@mattermost/types/integrations';
import type {Post} from '@mattermost/types/posts';
import {PostPriority} from '@mattermost/types/posts';
import {Posts} from 'mattermost-redux/constants';
import type {ActionResult} from 'mattermost-redux/types/actions';
import * as GlobalActions from 'actions/global_actions';
import type {Props} from 'components/advanced_create_post/advanced_create_post';
import AdvancedCreatePost from 'components/advanced_create_post/advanced_create_post';
import AdvanceTextEditor from 'components/advanced_text_editor/advanced_text_editor';
import type {Props} from 'components/advanced_create_post/advanced_create_post';
import type {TextboxElement} from 'components/textbox';
import {testComponentForLineBreak} from 'tests/helpers/line_break_helpers';
import {testComponentForMarkdownHotkeys} from 'tests/helpers/markdown_hotkey_helpers.js';
import Constants, {StoragePrefixes, ModalIdentifiers} from 'utils/constants';
import EmojiMap from 'utils/emoji_map';
import {execCommandInsertText} from 'utils/exec_commands';
import {TestHelper} from 'utils/test_helper';
import * as Utils from 'utils/utils';
jest.mock('actions/global_actions', () => ({
emitLocalUserTypingEvent: jest.fn(),
@@ -993,171 +986,6 @@ describe('components/advanced_create_post', () => {
expect(instance.handleFileUploadChange).toHaveBeenCalledTimes(1);
});
it('Should just return as ctrlSend is enabled and its ctrl+enter', () => {
const wrapper = shallow(advancedCreatePost({
ctrlSend: true,
}));
const instance: any = wrapper.instance();
instance.textboxRef.current = {blur: jest.fn()};
const target = {
selectionStart: 0,
selectionEnd: 0,
value: 'brown\nfox jumps over lazy dog',
};
const event = {
ctrlKey: true,
key: Constants.KeyCodes.ENTER[0],
keyCode: Constants.KeyCodes.ENTER[1],
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
persist: jest.fn(),
target,
} as unknown as React.KeyboardEvent<TextboxElement>;
instance.handleKeyDown(event);
setTimeout(() => {
expect(GlobalActions.emitLocalUserTypingEvent).toHaveBeenCalledWith(currentChannelProp.id, '');
}, 0);
});
it('Should call edit action as comment for arrow up', () => {
const setEditingPost = jest.fn();
const wrapper = shallow(advancedCreatePost({
actions: {
...baseProp.actions,
setEditingPost,
},
}));
const instance = wrapper.instance() as AdvancedCreatePost;
const type = Utils.localizeMessage('create_post.comment', Posts.MESSAGE_TYPES.COMMENT);
const target = {
selectionStart: 0,
selectionEnd: 0,
value: 'brown\nfox jumps over lazy dog',
};
const event = {
key: Constants.KeyCodes.UP[0],
keyCode: Constants.KeyCodes.UP[1],
preventDefault: jest.fn(),
persist: jest.fn(),
target,
} as unknown as React.KeyboardEvent<TextboxElement>;
instance.handleKeyDown(event);
expect(setEditingPost).toHaveBeenCalledWith(currentUsersLatestPostProp.id, 'post_textbox', type);
});
it('Should call edit action as post for arrow up', () => {
const setEditingPost = jest.fn();
const wrapper = shallow(advancedCreatePost({
actions: {
...baseProp.actions,
setEditingPost,
},
}));
const instance = wrapper.instance() as AdvancedCreatePost;
wrapper.setProps({
currentUsersLatestPost: {id: 'b', channel_id: currentChannelProp.id},
});
const type = Utils.localizeMessage('create_post.post', Posts.MESSAGE_TYPES.POST);
const target = {
selectionStart: 0,
selectionEnd: 0,
value: 'brown\nfox jumps over lazy dog',
};
const event = {
key: Constants.KeyCodes.UP[0],
keyCode: Constants.KeyCodes.UP[1],
preventDefault: jest.fn(),
persist: jest.fn(),
target,
} as unknown as React.KeyboardEvent<TextboxElement>;
instance.handleKeyDown(event);
expect(setEditingPost).toHaveBeenCalledWith(currentUsersLatestPostProp.id, 'post_textbox', type);
});
it('Should call moveHistoryIndexForward as ctrlKey and down arrow', () => {
const moveHistoryIndexForward = jest.fn(
() => {
return new Promise<void>((resolve) => {
process.nextTick(() => resolve());
});
},
);
const wrapper = shallow(advancedCreatePost({
actions: {
...baseProp.actions,
moveHistoryIndexForward,
},
}));
const instance = wrapper.instance() as AdvancedCreatePost;
const target = {
selectionStart: 0,
selectionEnd: 0,
value: 'brown\nfox jumps over lazy dog',
};
const event = {
ctrlKey: true,
key: Constants.KeyCodes.DOWN[0],
keyCode: Constants.KeyCodes.DOWN[1],
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
persist: jest.fn(),
target,
} as unknown as React.KeyboardEvent<TextboxElement>;
instance.handleKeyDown(event);
expect(moveHistoryIndexForward).toHaveBeenCalled();
});
it('Should call moveHistoryIndexBack as ctrlKey and up arrow', () => {
const moveHistoryIndexBack = jest.fn(
() => {
return new Promise<void>((resolve) => {
process.nextTick(() => resolve());
});
},
);
const wrapper = shallow(advancedCreatePost({
actions: {
...baseProp.actions,
moveHistoryIndexBack,
},
}));
const instance = wrapper.instance() as AdvancedCreatePost;
const target = {
selectionStart: 0,
selectionEnd: 0,
value: 'brown\nfox jumps over lazy dog',
};
const event = {
ctrlKey: true,
key: Constants.KeyCodes.UP[0],
keyCode: Constants.KeyCodes.UP[1],
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
persist: jest.fn(),
target,
} as unknown as React.KeyboardEvent<TextboxElement>;
instance.handleKeyDown(event);
expect(moveHistoryIndexBack).toHaveBeenCalled();
});
it('Show tutorial', () => {
const wrapper = shallow(advancedCreatePost({
showSendTutorialTip: true,
@@ -1300,129 +1128,6 @@ describe('components/advanced_create_post', () => {
);
});
it('should be able to format a pasted markdown table', () => {
const wrapper = shallow(advancedCreatePost());
const mockImpl = () => {
return {
setSelectionRange: jest.fn(),
focus: jest.fn(),
};
};
(wrapper.instance() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()};
const event = {
target: {
id: 'post_textbox',
},
preventDefault: jest.fn(),
clipboardData: {
items: [1],
types: ['text/html'],
getData: () => {
return '<table><tr><th>test</th><th>test</th></tr><tr><td>test</td><td>test</td></tr></table>';
},
},
} as unknown as ClipboardEvent;
const markdownTable = '| test | test |\n| --- | --- |\n| test | test |';
(wrapper.instance() as AdvancedCreatePost).pasteHandler(event);
expect(execCommandInsertText).toHaveBeenCalledWith(markdownTable);
});
it('should be able to format a pasted markdown table without headers', () => {
const wrapper = shallow(advancedCreatePost());
const mockImpl = () => {
return {
setSelectionRange: jest.fn(),
focus: jest.fn(),
};
};
(wrapper.instance() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()};
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>';
},
},
} as unknown as ClipboardEvent;
const markdownTable = '| test | test |\n| --- | --- |\n| test | test |\n';
(wrapper.instance() as AdvancedCreatePost).pasteHandler(event);
expect(execCommandInsertText).toHaveBeenCalledWith(markdownTable);
});
it('should be able to format a pasted hyperlink', () => {
const wrapper = shallow(advancedCreatePost());
const mockImpl = () => {
return {
setSelectionRange: jest.fn(),
focus: jest.fn(),
};
};
(wrapper.instance() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: 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>';
},
},
} as unknown as ClipboardEvent;
const markdownLink = '[link text](https://test.domain)';
(wrapper.instance() as AdvancedCreatePost).pasteHandler(event);
expect(execCommandInsertText).toHaveBeenCalledWith(markdownLink);
});
it('should be able to format a github codeblock (pasted as a table)', () => {
const wrapper = shallow(advancedCreatePost());
const mockImpl = () => {
return {
setSelectionRange: jest.fn(),
focus: jest.fn(),
};
};
(wrapper.instance() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()};
const event = {
target: {
id: 'post_textbox',
},
preventDefault: jest.fn(),
clipboardData: {
items: [1],
types: ['text/plain', 'text/html'],
getData: (type: string) => {
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>';
},
},
} as unknown as ClipboardEvent;
const codeBlockMarkdown = "```\n// a javascript codeblock example\nif (1 > 0) {\n return 'condition is true';\n}\n```";
(wrapper.instance() as AdvancedCreatePost).pasteHandler(event);
expect(execCommandInsertText).toHaveBeenCalledWith(codeBlockMarkdown);
});
/**
* TODO@all: move this test to advanced_text_editor.test.tsx and rewrite it according to the component
*
@@ -1471,26 +1176,6 @@ describe('components/advanced_create_post', () => {
false,
);
testComponentForMarkdownHotkeys(
(value: string) => advancedCreatePost({draft: {...draftProp, message: value}}),
(wrapper: any, setSelectionRangeFn: any) => {
wrapper.instance().textboxRef = {
current: {
getInputBox: jest.fn(() => {
return {
focus: jest.fn(),
setSelectionRange: setSelectionRangeFn,
};
}),
},
};
},
(instance: any) => instance.find(AdvanceTextEditor),
(instance: any) => instance.state().message,
false,
'post_textbox',
);
it('should match snapshot, can post; preview enabled', () => {
const wrapper = shallow(advancedCreatePost({canPost: true}));

Просмотреть файл

@@ -3,7 +3,6 @@
/* eslint-disable max-lines */
import {isNil} from 'lodash';
import React from 'react';
import type {Channel, ChannelMemberCountsByGroup} from '@mattermost/types/channels';
@@ -44,19 +43,9 @@ import Constants, {
AdvancedTextEditor as AdvancedTextEditorConst,
} from 'utils/constants';
import type EmojiMap from 'utils/emoji_map';
import {execCommandInsertText} from 'utils/exec_commands';
import * as Keyboard from 'utils/keyboard';
import {applyMarkdown} from 'utils/markdown/apply_markdown';
import type {ApplyMarkdownOptions} from 'utils/markdown/apply_markdown';
import {
getHtmlTable,
hasHtmlLink,
formatMarkdownMessage,
formatGithubCodePaste,
isGitHubCodeBlock,
formatMarkdownLinkMessage,
isTextUrl,
} from 'utils/paste';
import {
containsAtChannel,
specialMentionsInText,
@@ -67,7 +56,6 @@ import {
groupsMentionedInText,
mentionsMinusSpecialMentionsInText,
hasRequestedPersistentNotifications,
isWithinCodeBlock,
} from 'utils/post_utils';
import * as UserAgent from 'utils/user_agent';
import * as Utils from 'utils/utils';
@@ -325,7 +313,6 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
actions.setShowPreview(false);
actions.clearDraftUploads();
this.focusTextbox();
document.addEventListener('paste', this.pasteHandler);
document.addEventListener('keydown', this.documentKeyHandler);
window.addEventListener('beforeunload', this.unloadHandler);
this.setOrientationListeners();
@@ -357,7 +344,6 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
}
componentWillUnmount() {
document.removeEventListener('paste', this.pasteHandler);
document.removeEventListener('keydown', this.documentKeyHandler);
window.removeEventListener('beforeunload', this.unloadHandler);
this.removeOrientationListeners();
@@ -928,43 +914,6 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
this.draftsForChannel[channelId] = draft;
};
pasteHandler = (event: ClipboardEvent) => {
const {clipboardData, target} = event;
if (!clipboardData || !clipboardData.items || !target || ((target as TextboxElement)?.id !== 'post_textbox')) {
return;
}
const {selectionStart, selectionEnd} = target as TextboxElement;
const hasSelection = !isNil(selectionStart) && !isNil(selectionEnd) && selectionStart < selectionEnd;
const hasTextUrl = isTextUrl(clipboardData);
const hasHTMLLinks = !this.isNonFormattedPaste && hasHtmlLink(clipboardData);
const htmlTable = getHtmlTable(clipboardData);
const shouldApplyLinkMarkdown = hasSelection && hasTextUrl;
const shouldApplyGithubCodeBlock = htmlTable && isGitHubCodeBlock(htmlTable.className);
if (!htmlTable && !hasHTMLLinks && !shouldApplyLinkMarkdown) {
return;
}
event.preventDefault();
const message = this.state.message;
// 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);
}
};
handleFileUploadChange = () => {
this.focusTextbox();
};
@@ -1128,11 +1077,9 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
fillMessageFromHistory() {
const lastMessage = this.props.messageInHistoryItem;
if (lastMessage) {
this.setState({
message: lastMessage,
});
}
this.setState({
message: lastMessage || '',
});
}
handleMouseUpKeyUp = (e: React.MouseEvent | React.KeyboardEvent) => {
@@ -1141,194 +1088,6 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
});
};
handleKeyDown = (e: React.KeyboardEvent<TextboxElement>) => {
const messageIsEmpty = this.state.message.length === 0;
const draftMessageIsEmpty = this.props.draft.message.length === 0;
const ctrlOrMetaKeyPressed = e.ctrlKey || e.metaKey;
// fix for FF not capturing the paste without formatting event when using ctrl|cmd + shift + v
if (e.key === KeyCodes.V[0] && ctrlOrMetaKeyPressed) {
if (e.shiftKey) {
this.isNonFormattedPaste = true;
this.timeoutId = window.setTimeout(() => {
this.isNonFormattedPaste = false;
}, 250);
}
}
const ctrlEnterKeyCombo = (this.props.ctrlSend || this.props.codeBlockOnCtrlEnter) &&
Keyboard.isKeyPressed(e, KeyCodes.ENTER) &&
ctrlOrMetaKeyPressed;
const ctrlKeyCombo = Keyboard.cmdOrCtrlPressed(e) && !e.altKey && !e.shiftKey;
const ctrlAltCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.altKey;
const shiftAltCombo = !Keyboard.cmdOrCtrlPressed(e) && e.shiftKey && e.altKey;
// listen for line break key combo and insert new line character
if (Utils.isUnhandledLineBreakKeyCombo(e)) {
this.setState({message: Utils.insertLineBreakFromKeyEvent(e)});
return;
}
if (ctrlEnterKeyCombo) {
this.setShowPreview(false);
this.postMsgKeyPress(e);
return;
}
const {message, caretPosition} = this.state;
const caretIsWithinCodeBlock = isWithinCodeBlock(message, caretPosition);
if (Keyboard.isKeyPressed(e, KeyCodes.ESCAPE)) {
this.textboxRef.current?.blur();
}
if (
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!e.shiftKey &&
Keyboard.isKeyPressed(e, KeyCodes.UP) &&
message === ''
) {
e.preventDefault();
if (this.textboxRef.current) {
this.textboxRef.current.blur();
}
this.editLastPost(e);
}
const {
selectionStart,
selectionEnd,
value,
} = e.target as TextboxElement;
if (ctrlKeyCombo && !caretIsWithinCodeBlock) {
if (draftMessageIsEmpty && Keyboard.isKeyPressed(e, KeyCodes.UP)) {
e.stopPropagation();
e.preventDefault();
this.loadPrevMessage(e);
} else if (draftMessageIsEmpty && Keyboard.isKeyPressed(e, KeyCodes.DOWN)) {
e.stopPropagation();
e.preventDefault();
this.loadNextMessage(e);
} else if (Keyboard.isKeyPressed(e, KeyCodes.B)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
markdownMode: 'bold',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.I)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
markdownMode: 'italic',
selectionStart,
selectionEnd,
message: value,
});
} else if (Utils.isTextSelectedInPostOrReply(e) && Keyboard.isKeyPressed(e, KeyCodes.K)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
markdownMode: 'link',
selectionStart,
selectionEnd,
message: value,
});
}
} else if (ctrlAltCombo && !caretIsWithinCodeBlock) {
if (Keyboard.isKeyPressed(e, KeyCodes.K)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
markdownMode: 'link',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.C)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
markdownMode: 'code',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.E)) {
e.stopPropagation();
e.preventDefault();
this.toggleEmojiPicker();
} else if (Keyboard.isKeyPressed(e, KeyCodes.T)) {
e.stopPropagation();
e.preventDefault();
this.toggleAdvanceTextEditor();
} else if (Keyboard.isKeyPressed(e, KeyCodes.P) && message.length) {
e.stopPropagation();
e.preventDefault();
this.setShowPreview(!this.props.shouldShowPreview);
}
} else if (shiftAltCombo && !caretIsWithinCodeBlock) {
if (Keyboard.isKeyPressed(e, KeyCodes.X)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
markdownMode: 'strike',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.SEVEN)) {
e.preventDefault();
this.applyMarkdown({
markdownMode: 'ol',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.EIGHT)) {
e.preventDefault();
this.applyMarkdown({
markdownMode: 'ul',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.NINE)) {
e.preventDefault();
this.applyMarkdown({
markdownMode: 'quote',
selectionStart,
selectionEnd,
message: value,
});
}
}
const upKeyOnly = !ctrlOrMetaKeyPressed && !e.altKey && !e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.UP);
const shiftUpKeyCombo = !ctrlOrMetaKeyPressed && !e.altKey && e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.UP);
const ctrlShiftCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.shiftKey;
if (upKeyOnly && messageIsEmpty) {
this.editLastPost(e);
} else if (shiftUpKeyCombo && messageIsEmpty) {
this.replyToLastPost(e);
} else if (ctrlShiftCombo && Keyboard.isKeyPressed(e, KeyCodes.E)) {
e.stopPropagation();
e.preventDefault();
this.toggleEmojiPicker();
} else if (((UserAgent.isMac() && ctrlShiftCombo) || (!UserAgent.isMac() && ctrlAltCombo)) && Keyboard.isKeyPressed(e, KeyCodes.P) && this.state.message.length) {
this.setShowPreview(!this.props.shouldShowPreview);
} else if (ctrlAltCombo && Keyboard.isKeyPressed(e, KeyCodes.T)) {
this.toggleAdvanceTextEditor();
}
};
editLastPost = (e: React.KeyboardEvent) => {
e.preventDefault();
@@ -1580,6 +1339,14 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
return Object.values(this.getSpecialMentions()).includes(true);
};
onMessageChange = (message: string, callback?: (() => void) | undefined) => {
this.handleDraftChange({
...this.props.draft,
message,
});
this.setState({message}, callback);
};
render() {
const {draft, canPost} = this.props;
@@ -1666,7 +1433,6 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
handlePostError={this.handlePostError}
emitTypingEvent={this.emitTypingEvent}
handleMouseUpKeyUp={this.handleMouseUpKeyUp}
handleKeyDown={this.handleKeyDown}
postMsgKeyPress={this.postMsgKeyPress}
handleChange={this.handleChange}
toggleEmojiPicker={this.toggleEmojiPicker}
@@ -1707,6 +1473,14 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
),
...(pluginItems || []),
].filter(Boolean)}
codeBlockOnCtrlEnter={this.props.codeBlockOnCtrlEnter}
ctrlSend={this.props.ctrlSend}
loadNextMessage={this.loadNextMessage}
loadPrevMessage={this.loadPrevMessage}
onEditLatestPost={this.editLastPost}
onMessageChange={this.onMessageChange}
replyToLastPost={this.replyToLastPost}
caretPosition={this.state.caretPosition}
/>
</form>
);

Просмотреть файл

@@ -44,7 +44,7 @@ import {searchAssociatedGroupsForReference} from 'actions/views/group';
import {openModal} from 'actions/views/modals';
import {selectPostFromRightHandSideSearchByPostId} from 'actions/views/rhs';
import {setShowPreviewOnCreatePost} from 'actions/views/textbox';
import {getEmojiMap, getShortcutReactToLastPostEmittedFrom} from 'selectors/emojis';
import {getEmojiMap} from 'selectors/emojis';
import {getCurrentLocale} from 'selectors/i18n';
import {makeGetChannelDraft, getIsRhsExpanded, getIsRhsOpen} from 'selectors/rhs';
import {connectionErrorCount} from 'selectors/views/system';
@@ -81,7 +81,6 @@ function makeMapStateToProps() {
const userIsOutOfOffice = getStatusForUserId(state, currentUserId) === UserStatuses.OUT_OF_OFFICE;
const badConnection = connectionErrorCount(state) > 1;
const isTimezoneEnabled = config.ExperimentalTimezone === 'true';
const shortcutReactToLastPostEmittedFrom = getShortcutReactToLastPostEmittedFrom(state);
const canPost = haveICurrentChannelPermission(state, Permissions.CREATE_POST);
const useChannelMentions = haveICurrentChannelPermission(state, Permissions.USE_CHANNEL_MENTIONS);
const isLDAPEnabled = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true';
@@ -128,7 +127,6 @@ function makeMapStateToProps() {
emojiMap: getEmojiMap(state),
badConnection,
isTimezoneEnabled,
shortcutReactToLastPostEmittedFrom,
canPost,
useChannelMentions,
shouldShowPreview: showPreviewOnCreatePost(state),

Просмотреть файл

@@ -0,0 +1,292 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
import Permissions from 'mattermost-redux/constants/permissions';
import type {FileUpload} from 'components/file_upload/file_upload';
import type Textbox from 'components/textbox/textbox';
import WebSocketClient from 'client/web_websocket_client';
import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
import {renderWithFullContext, userEvent} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import {WebSocketContext} from 'utils/use_websocket';
import type {PostDraft} from 'types/store/draft';
import AdavancedTextEditor from './advanced_text_editor';
global.ResizeObserver = require('resize-observer-polyfill');
const currentUserId = 'current_user_id';
const channelId = 'current_channel_id';
const initialState = {
entities: {
general: {
config: {
EnableConfirmNotificationsToChannel: 'false',
EnableCustomGroups: 'false',
PostPriority: 'false',
ExperimentalTimezone: 'false',
EnableCustomEmoji: 'false',
AllowSyncedDrafts: 'false',
},
license: {
IsLicensed: 'false',
LDAPGroups: 'false',
},
},
channels: {
channels: {
current_channel_id: TestHelper.getChannelMock({id: 'current_channel_id', team_id: 'current_team_id'}),
},
stats: {
current_channel_id: {
member_count: 1,
},
},
roles: {
current_channel_id: new Set(['channel_roles']),
},
},
teams: {
currentTeamId: 'current_team_id',
teams: {
current_team_id: TestHelper.getTeamMock({id: 'current_team_id'}),
},
myMembers: {
current_team_id: TestHelper.getTeamMembershipMock({roles: 'team_roles'}),
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: TestHelper.getUserMock({id: 'current_user_id', roles: 'user_roles'}),
},
statuses: {
current_user_id: 'online',
},
},
},
websocket: {
connectionId: 'connection_id',
},
};
const emptyDraft: PostDraft = {
message: '',
uploadsInProgress: [],
fileInfos: [],
channelId,
rootId: '',
createAt: 0,
updateAt: 0,
};
const baseProps = {
location: 'CENTER',
message: '',
showEmojiPicker: false,
uploadsProgressPercent: {},
currentChannel: initialState.entities.channels.channels.current_channel_id as Channel,
channelId,
postId: '',
errorClass: null,
serverError: null,
postError: null,
isFormattingBarHidden: false,
draft: emptyDraft,
badConnection: false,
handleSubmit: jest.fn(),
removePreview: jest.fn(),
showSendTutorialTip: false,
setShowPreview: jest.fn(),
shouldShowPreview: false,
maxPostSize: 100,
canPost: true,
applyMarkdown: jest.fn(),
useChannelMentions: false,
currentChannelTeammateUsername: '',
currentUserId,
canUploadFiles: true,
enableEmojiPicker: true,
enableGifPicker: true,
handleBlur: jest.fn(),
handlePostError: jest.fn(),
emitTypingEvent: jest.fn(),
handleMouseUpKeyUp: jest.fn(),
postMsgKeyPress: jest.fn(),
handleChange: jest.fn(),
toggleEmojiPicker: jest.fn(),
handleGifClick: jest.fn(),
handleEmojiClick: jest.fn(),
hideEmojiPicker: jest.fn(),
toggleAdvanceTextEditor: jest.fn(),
handleUploadProgress: jest.fn(),
handleUploadError: jest.fn(),
handleFileUploadComplete: jest.fn(),
handleUploadStart: jest.fn(),
handleFileUploadChange: jest.fn(),
getFileUploadTarget: jest.fn(),
fileUploadRef: React.createRef<FileUpload>(),
prefillMessage: jest.fn(),
textboxRef: React.createRef<Textbox>(),
isThreadView: false,
ctrlSend: true,
codeBlockOnCtrlEnter: true,
onMessageChange: jest.fn(),
onEditLatestPost: jest.fn(),
loadPrevMessage: jest.fn(),
loadNextMessage: jest.fn(),
replyToLastPost: jest.fn(),
caretPosition: 0,
};
describe('components/avanced_text_editor/advanced_text_editor', () => {
describe('keyDown behavior', () => {
it('Enter should call postMsgKeyPress', () => {
const postMsgKeyPress = jest.fn();
renderWithFullContext(
<WebSocketContext.Provider value={WebSocketClient}>
<AdavancedTextEditor
{...baseProps}
postMsgKeyPress={postMsgKeyPress}
message={'test'}
/>
</WebSocketContext.Provider>, mergeObjects(initialState, {
entities: {
roles: {
roles: {
user_roles: {permissions: [Permissions.CREATE_POST]},
},
},
},
}));
userEvent.type(screen.getByTestId('post_textbox'), '{enter}');
expect(postMsgKeyPress).toHaveBeenCalledTimes(1);
});
it('Ctrl+up should call loadPrevMessage', () => {
const loadPrevMessage = jest.fn();
renderWithFullContext(
<WebSocketContext.Provider value={WebSocketClient}>
<AdavancedTextEditor
{...baseProps}
loadPrevMessage={loadPrevMessage}
/>
</WebSocketContext.Provider>, mergeObjects(initialState, {
entities: {
roles: {
roles: {
user_roles: {permissions: [Permissions.CREATE_POST]},
},
},
},
}));
userEvent.type(screen.getByTestId('post_textbox'), '{ctrl}{arrowup}');
expect(loadPrevMessage).toHaveBeenCalledTimes(1);
});
it('up should call onEditLatestPost', () => {
const onEditLatestPost = jest.fn();
renderWithFullContext(
<WebSocketContext.Provider value={WebSocketClient}>
<AdavancedTextEditor
{...baseProps}
onEditLatestPost={onEditLatestPost}
/>
</WebSocketContext.Provider>, mergeObjects(initialState, {
entities: {
roles: {
roles: {
user_roles: {permissions: [Permissions.CREATE_POST]},
},
},
},
}));
userEvent.type(screen.getByTestId('post_textbox'), '{arrowup}');
expect(onEditLatestPost).toHaveBeenCalledTimes(1);
});
it('ESC should blur the input', () => {
renderWithFullContext(
<WebSocketContext.Provider value={WebSocketClient}>
<AdavancedTextEditor
{...baseProps}
/>
</WebSocketContext.Provider>, mergeObjects(initialState, {
entities: {
roles: {
roles: {
user_roles: {permissions: [Permissions.CREATE_POST]},
},
},
},
}));
const textbox = screen.getByTestId('post_textbox');
userEvent.type(textbox, 'something{esc}');
expect(textbox).not.toHaveFocus();
});
describe('markdown', () => {
const ttcc = [
{
input: '{ctrl}b',
markdownMode: 'bold',
},
{
input: '{ctrl}i',
markdownMode: 'italic',
},
{
input: '{ctrl}k',
markdownMode: 'link',
},
{
input: '{ctrl}{alt}k',
markdownMode: 'link',
},
];
for (const tc of ttcc) {
it(`component adds ${tc.markdownMode} markdown`, () => {
const applyMarkdown = jest.fn();
const message = 'Some markdown text';
const selectionStart = 5;
const selectionEnd = 10;
renderWithFullContext(
<WebSocketContext.Provider value={WebSocketClient}>
<AdavancedTextEditor
{...baseProps}
applyMarkdown={applyMarkdown}
message={'Some markdown text'}
/>
</WebSocketContext.Provider>, mergeObjects(initialState, {
entities: {
roles: {
roles: {
user_roles: {permissions: [Permissions.CREATE_POST]},
},
},
},
}));
const textbox = screen.getByTestId('post_textbox');
userEvent.type(textbox, tc.input, {initialSelectionStart: selectionStart, initialSelectionEnd: selectionEnd});
expect(applyMarkdown).toHaveBeenCalledWith({
markdownMode: tc.markdownMode,
selectionStart,
selectionEnd,
message,
});
});
}
});
});
});

Просмотреть файл

@@ -4,6 +4,7 @@
import classNames from 'classnames';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import {EmoticonHappyOutlineIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
@@ -11,6 +12,8 @@ import type {Emoji} from '@mattermost/types/emojis';
import type {ServerError} from '@mattermost/types/errors';
import type {FileInfo} from '@mattermost/types/files';
import {emitShortcutReactToLastPostFrom} from 'actions/post_actions';
import AutoHeightSwitcher from 'components/common/auto_height_switcher';
import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay';
import FilePreview from 'components/file_preview';
@@ -29,7 +32,11 @@ import Tooltip from 'components/tooltip';
import {SendMessageTour} from 'components/tours/onboarding_tour';
import Constants, {Locations} from 'utils/constants';
import * as Keyboard from 'utils/keyboard';
import type {ApplyMarkdownOptions} from 'utils/markdown/apply_markdown';
import {pasteHandler} from 'utils/paste';
import {isWithinCodeBlock} from 'utils/post_utils';
import * as UserAgent from 'utils/user_agent';
import * as Utils from 'utils/utils';
import type {PostDraft} from 'types/store/draft';
@@ -44,6 +51,8 @@ import ToggleFormattingBar from './toggle_formatting_bar';
import './advanced_text_editor.scss';
const KeyCodes = Constants.KeyCodes;
type Props = {
/**
@@ -78,7 +87,6 @@ type Props = {
handlePostError: (postError: React.ReactNode) => void;
emitTypingEvent: () => void;
handleMouseUpKeyUp: (e: React.MouseEvent<TextboxElement> | React.KeyboardEvent<TextboxElement>) => void;
handleKeyDown: (e: React.KeyboardEvent<TextboxElement>) => void;
postMsgKeyPress: (e: React.KeyboardEvent<TextboxElement>) => void;
handleChange: (e: React.ChangeEvent<TextboxElement>) => void;
toggleEmojiPicker: () => void;
@@ -101,6 +109,14 @@ type Props = {
additionalControls?: React.ReactNodeArray;
labels?: React.ReactNode;
disableSend?: boolean;
ctrlSend?: boolean;
codeBlockOnCtrlEnter?: boolean;
onMessageChange: (message: string, callback?: () => void) => void;
onEditLatestPost: (e: React.KeyboardEvent) => void;
loadPrevMessage: (e: React.KeyboardEvent) => void;
loadNextMessage: (e: React.KeyboardEvent) => void;
replyToLastPost?: (e: React.KeyboardEvent) => void;
caretPosition: number;
placeholder?: string;
}
@@ -136,7 +152,6 @@ const AdvanceTextEditor = ({
handlePostError,
emitTypingEvent,
handleMouseUpKeyUp,
handleKeyDown,
postMsgKeyPress,
handleChange,
toggleEmojiPicker,
@@ -157,6 +172,14 @@ const AdvanceTextEditor = ({
additionalControls,
labels,
disableSend = false,
ctrlSend,
codeBlockOnCtrlEnter,
onMessageChange,
onEditLatestPost,
loadPrevMessage,
loadNextMessage,
replyToLastPost,
caretPosition,
placeholder,
}: Props) => {
const readOnlyChannel = !canPost;
@@ -173,6 +196,11 @@ const AdvanceTextEditor = ({
const [showFormattingSpacer, setShowFormattingSpacer] = useState(shouldShowPreview);
const [keepEditorInFocus, setKeepEditorInFocus] = useState(false);
const isNonFormattedPaste = useRef(false);
const timeoutId = useRef<number>();
const dispatch = useDispatch();
const input = textboxRef.current?.getInputBox();
const handleHeightChange = useCallback((height: number, maxHeight: number) => {
@@ -192,6 +220,8 @@ const AdvanceTextEditor = ({
setKeepEditorInFocus(true);
}, []);
const isRHS = location === Locations.RHS_COMMENT;
let attachmentPreview = null;
if (!readOnlyChannel && (draft.fileInfos.length > 0 || draft.uploadsInProgress.length > 0)) {
attachmentPreview = (
@@ -370,6 +400,205 @@ const AdvanceTextEditor = ({
}
}, [message, input]);
const handleKeyDown = (e: React.KeyboardEvent<TextboxElement>) => {
const ctrlOrMetaKeyPressed = e.ctrlKey || e.metaKey;
const ctrlEnterKeyCombo = (ctrlSend || codeBlockOnCtrlEnter) &&
Keyboard.isKeyPressed(e, KeyCodes.ENTER) &&
ctrlOrMetaKeyPressed;
const ctrlKeyCombo = Keyboard.cmdOrCtrlPressed(e) && !e.altKey && !e.shiftKey;
const ctrlAltCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.altKey;
const shiftAltCombo = !Keyboard.cmdOrCtrlPressed(e) && e.shiftKey && e.altKey;
const ctrlShiftCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.shiftKey;
// fix for FF not capturing the paste without formatting event when using ctrl|cmd + shift + v
if (e.key === KeyCodes.V[0] && ctrlOrMetaKeyPressed) {
if (e.shiftKey) {
isNonFormattedPaste.current = true;
timeoutId.current = window.setTimeout(() => {
isNonFormattedPaste.current = false;
}, 250);
}
}
// listen for line break key combo and insert new line character
if (Utils.isUnhandledLineBreakKeyCombo(e)) {
onMessageChange(Utils.insertLineBreakFromKeyEvent(e));
return;
}
if (ctrlEnterKeyCombo) {
setShowPreview(false);
postMsgKeyPress(e);
return;
}
if (Keyboard.isKeyPressed(e, KeyCodes.ESCAPE)) {
textboxRef.current?.blur();
}
const upKeyOnly = !ctrlOrMetaKeyPressed && !e.altKey && !e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.UP);
const messageIsEmpty = message.length === 0;
const draftMessageIsEmpty = draft.message.length === 0;
const caretIsWithinCodeBlock = caretPosition && isWithinCodeBlock(message, caretPosition);
if (upKeyOnly && messageIsEmpty) {
e.preventDefault();
if (textboxRef.current) {
textboxRef.current.blur();
}
onEditLatestPost(e);
}
const {
selectionStart,
selectionEnd,
value,
} = e.target as TextboxElement;
if (ctrlKeyCombo && !caretIsWithinCodeBlock) {
if (draftMessageIsEmpty && Keyboard.isKeyPressed(e, KeyCodes.UP)) {
e.stopPropagation();
e.preventDefault();
loadPrevMessage(e);
} else if (draftMessageIsEmpty && Keyboard.isKeyPressed(e, KeyCodes.DOWN)) {
e.stopPropagation();
e.preventDefault();
loadNextMessage(e);
} else if (Keyboard.isKeyPressed(e, KeyCodes.B)) {
e.stopPropagation();
e.preventDefault();
applyMarkdown({
markdownMode: 'bold',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.I)) {
e.stopPropagation();
e.preventDefault();
applyMarkdown({
markdownMode: 'italic',
selectionStart,
selectionEnd,
message: value,
});
} else if (Utils.isTextSelectedInPostOrReply(e) && Keyboard.isKeyPressed(e, KeyCodes.K)) {
e.stopPropagation();
e.preventDefault();
applyMarkdown({
markdownMode: 'link',
selectionStart,
selectionEnd,
message: value,
});
}
} else if (ctrlAltCombo && !caretIsWithinCodeBlock) {
if (Keyboard.isKeyPressed(e, KeyCodes.K)) {
e.stopPropagation();
e.preventDefault();
applyMarkdown({
markdownMode: 'link',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.C)) {
e.stopPropagation();
e.preventDefault();
applyMarkdown({
markdownMode: 'code',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.E)) {
e.stopPropagation();
e.preventDefault();
toggleEmojiPicker();
} else if (Keyboard.isKeyPressed(e, KeyCodes.T)) {
e.stopPropagation();
e.preventDefault();
toggleAdvanceTextEditor();
} else if (Keyboard.isKeyPressed(e, KeyCodes.P) && message.length && !UserAgent.isMac()) {
e.stopPropagation();
e.preventDefault();
setShowPreview(!shouldShowPreview);
}
} else if (shiftAltCombo && !caretIsWithinCodeBlock) {
if (Keyboard.isKeyPressed(e, KeyCodes.X)) {
e.stopPropagation();
e.preventDefault();
applyMarkdown({
markdownMode: 'strike',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.SEVEN)) {
e.preventDefault();
applyMarkdown({
markdownMode: 'ol',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.EIGHT)) {
e.preventDefault();
applyMarkdown({
markdownMode: 'ul',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.NINE)) {
e.preventDefault();
applyMarkdown({
markdownMode: 'quote',
selectionStart,
selectionEnd,
message: value,
});
}
} else if (ctrlShiftCombo && !caretIsWithinCodeBlock) {
if (Keyboard.isKeyPressed(e, KeyCodes.P) && message.length && UserAgent.isMac()) {
e.stopPropagation();
e.preventDefault();
setShowPreview(!shouldShowPreview);
} else if (Keyboard.isKeyPressed(e, KeyCodes.E)) {
e.stopPropagation();
e.preventDefault();
toggleEmojiPicker();
}
}
if (isRHS) {
const lastMessageReactionKeyCombo = ctrlShiftCombo && Keyboard.isKeyPressed(e, KeyCodes.BACK_SLASH);
if (lastMessageReactionKeyCombo) {
e.stopPropagation();
e.preventDefault();
dispatch(emitShortcutReactToLastPostFrom(Locations.RHS_ROOT));
}
} else {
const shiftUpKeyCombo = !ctrlOrMetaKeyPressed && !e.altKey && e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.UP);
if (shiftUpKeyCombo && messageIsEmpty) {
replyToLastPost?.(e);
}
}
};
useEffect(() => {
function onPaste(event: ClipboardEvent) {
pasteHandler(event, location, message, isNonFormattedPaste.current, caretPosition);
}
document.addEventListener('paste', onPaste);
return () => {
document.removeEventListener('paste', onPaste);
};
}, [location, message, caretPosition]);
useEffect(() => {
if (!message) {
handleWidthChange(0);

Просмотреть файл

@@ -326,6 +326,8 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
Keyboard.isKeyPressed(e, KeyCodes.ENTER) &&
ctrlOrMetaKeyPressed;
const markdownLinkKey = Keyboard.isKeyPressed(e, KeyCodes.K);
const ctrlShiftCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.shiftKey;
const lastMessageReactionKeyCombo = ctrlShiftCombo && Keyboard.isKeyPressed(e, KeyCodes.BACK_SLASH);
// listen for line break key combo and insert new line character
if (Utils.isUnhandledLineBreakKeyCombo(e)) {
@@ -356,6 +358,10 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
selectionEnd: e.currentTarget.selectionEnd,
message: e.currentTarget.value,
});
} else if (lastMessageReactionKeyCombo) {
// Stop document from handling the hotkey and opening the reaction
e.stopPropagation();
e.preventDefault();
}
};

Просмотреть файл

@@ -66,8 +66,9 @@ const PostOptions = (props: Props): JSX.Element => {
const [showActionsMenu, setShowActionsMenu] = useState(false);
useEffect(() => {
const locationToUse = props.location === 'RHS_COMMENT' ? Locations.RHS_ROOT : props.location;
if (props.isLastPost &&
(props.shortcutReactToLastPostEmittedFrom === props.location) &&
(props.shortcutReactToLastPostEmittedFrom === locationToUse) &&
props.isPostHeaderVisible) {
toggleEmojiPicker();
props.actions.emitShortcutReactToLastPostFrom(Locations.NO_WHERE);

Просмотреть файл

@@ -1,311 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @module makrdownHotkeyHelpers
* consolidate testing of similar behavior across components
*/
import {shallow} from 'enzyme';
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
import Constants from 'utils/constants';
/**
* @param {string} [input] text input
* @param {int} [start] selection's start index
* @param {int} [end] selection's end index
* @param {object} [keycode] Keycode constant associated with key press
* @return {object} keydown event object
*/
export function makeSelectionEvent(input, start, end) {
return {
preventDefault: jest.fn(),
target: {
selectionStart: start,
selectionEnd: end,
value: input,
},
};
}
function makeMarkdownHotkeyEvent(input, start, end, keycode, altKey = false, targetId = 'root') {
return {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
ctrlKey: true,
altKey,
key: keycode[0],
keyCode: keycode[1],
target: {
selectionStart: start,
selectionEnd: end,
value: input,
id: targetId,
},
};
}
/**
* @param {string} [input] text input
* @param {int} [start] selection's start index
* @param {int} [end] selection's end index
* @return {object} keydown event object
*/
export function makeBoldHotkeyEvent(input, start, end) {
return makeMarkdownHotkeyEvent(input, start, end, Constants.KeyCodes.B);
}
/**
* @param {string} [input] text input
* @param {int} [start] selection's start index
* @param {int} [end] selection's end index
* @return {object} keydown event object
*/
export function makeItalicHotkeyEvent(input, start, end) {
return makeMarkdownHotkeyEvent(input, start, end, Constants.KeyCodes.I);
}
function makeLinkHotKeyWithoutAltKeyEvent(input, start, end, targetId) {
return makeMarkdownHotkeyEvent(input, start, end, Constants.KeyCodes.K, false, targetId);
}
function makeLinkHotKeyEvent(input, start, end) {
return makeMarkdownHotkeyEvent(input, start, end, Constants.KeyCodes.K, true);
}
/**
* helper to test markdown hotkeys on key down behavior common to many textarea inputs
* @param {function} generateInstance - single paramater "value" of the initial value
* @param {function} initRefs - React Component instance and setSelectionRange function
* @param {function} getValue - single parameter for the React Component instance
* NOTE: runs Jest tests
*/
export function testComponentForMarkdownHotkeys(generateInstance, initRefs, find, getValue, intlInjected = true, targetId) {
const shallowRender = intlInjected ? shallowWithIntl : shallow;
test('component adds bold markdown', () => {
// "Fafda" is selected with ctrl + B hotkey
const input = 'Jalebi Fafda & Sambharo';
const e = makeBoldHotkeyEvent(input, 7, 12);
const instance = shallowRender(generateInstance(input));
const setSelectionRange = jest.fn();
initRefs(instance, setSelectionRange);
find(instance).props().onKeyDown?.(e);
find(instance).props().handleKeyDown?.(e);
expect(getValue(instance)).toBe('Jalebi **Fafda** & Sambharo');
expect(setSelectionRange).toHaveBeenCalled();
});
test('component adds italic markdown', () => {
// "Fafda" is selected with ctrl + I hotkey
const input = 'Jalebi Fafda & Sambharo';
const e = makeItalicHotkeyEvent(input, 7, 12);
const instance = shallowRender(generateInstance(input));
const setSelectionRange = jest.fn();
initRefs(instance, setSelectionRange);
find(instance).props().onKeyDown?.(e);
find(instance).props().handleKeyDown?.(e);
expect(getValue(instance)).toBe('Jalebi *Fafda* & Sambharo');
expect(setSelectionRange).toHaveBeenCalled();
});
test('component starts bold markdown', () => {
// Nothing is selected, caret is just before "Fafde" with ctrl + B
const input = 'Jalebi Fafda & Sambharo';
const e = makeBoldHotkeyEvent(input, 7, 7);
const instance = shallowRender(generateInstance(input));
const setSelectionRange = jest.fn();
initRefs(instance, setSelectionRange);
find(instance).props().onKeyDown?.(e);
find(instance).props().handleKeyDown?.(e);
expect(getValue(instance)).toBe('Jalebi ****Fafda & Sambharo');
expect(setSelectionRange).toHaveBeenCalled();
});
test('component starts italic markdown', () => {
// Nothing is selected, caret is just before "Fafde" with ctrl + B
const input = 'Jalebi Fafda & Sambharo';
const e = makeItalicHotkeyEvent(input, 7, 7);
const instance = shallowRender(generateInstance(input));
const setSelectionRange = jest.fn();
initRefs(instance, setSelectionRange);
find(instance).props().onKeyDown?.(e);
find(instance).props().handleKeyDown?.(e);
expect(getValue(instance)).toBe('Jalebi **Fafda & Sambharo');
expect(setSelectionRange).toHaveBeenCalled();
});
test('component adds link markdown with hitting Ctrl + K when something is selected and event target has specific targetId', () => {
// "Fafda" is selected with ctrl + K hotkey
const input = 'Jalebi Fafda & Sambharo';
const e = makeLinkHotKeyWithoutAltKeyEvent(input, 7, 12, targetId);
const instance = shallowRender(generateInstance(input));
let selectionStart = -1;
let selectionEnd = -1;
const setSelectionRange = jest.fn((start, end) => {
selectionStart = start;
selectionEnd = end;
});
initRefs(instance, setSelectionRange);
find(instance).props().onKeyDown?.(e);
find(instance).props().handleKeyDown?.(e);
expect(getValue(instance)).toBe('Jalebi [Fafda](url) & Sambharo');
expect(setSelectionRange).toHaveBeenCalled();
expect(selectionStart).toBe(15);
expect(selectionEnd).toBe(18);
});
test('component adds link markdown when something is selected', () => {
// "Fafda" is selected with ctrl + alt + K hotkey
const input = 'Jalebi Fafda & Sambharo';
const e = makeLinkHotKeyEvent(input, 7, 12);
const instance = shallowRender(generateInstance(input));
let selectionStart = -1;
let selectionEnd = -1;
const setSelectionRange = jest.fn((start, end) => {
selectionStart = start;
selectionEnd = end;
});
initRefs(instance, setSelectionRange);
find(instance).props().onKeyDown?.(e);
find(instance).props().handleKeyDown?.(e);
expect(getValue(instance)).toBe('Jalebi [Fafda](url) & Sambharo');
expect(setSelectionRange).toHaveBeenCalled();
expect(selectionStart).toBe(15);
expect(selectionEnd).toBe(18);
});
test('component adds link markdown when cursor is before a word', () => {
// Cursor is before "Fafda" with ctrl + alt + K hotkey
const input = 'Jalebi Fafda & Sambharo';
const e = makeLinkHotKeyEvent(input, 7, 7);
const instance = shallowRender(generateInstance(input));
let selectionStart = -1;
let selectionEnd = -1;
const setSelectionRange = jest.fn((start, end) => {
selectionStart = start;
selectionEnd = end;
});
initRefs(instance, setSelectionRange);
find(instance).props().onKeyDown?.(e);
find(instance).props().handleKeyDown?.(e);
expect(getValue(instance)).toBe('Jalebi [Fafda](url) & Sambharo');
expect(setSelectionRange).toHaveBeenCalled();
expect(selectionStart).toBe(15);
expect(selectionEnd).toBe(18);
});
test('component adds link markdown when cursor is in a word', () => {
// Cursor is after "Fafda" with ctrl + alt + K hotkey
const input = 'Jalebi Fafda & Sambharo';
const e = makeLinkHotKeyEvent(input, 10, 10);
const instance = shallowRender(generateInstance(input));
let selectionStart = -1;
let selectionEnd = -1;
const setSelectionRange = jest.fn((start, end) => {
selectionStart = start;
selectionEnd = end;
});
initRefs(instance, setSelectionRange);
find(instance).props().onKeyDown?.(e);
find(instance).props().handleKeyDown?.(e);
expect(getValue(instance)).toBe('Jalebi [Fafda](url) & Sambharo');
expect(setSelectionRange).toHaveBeenCalled();
expect(selectionStart).toBe(15);
expect(selectionEnd).toBe(18);
});
test('component adds link markdown when cursor is after a word', () => {
// Cursor is after "Fafda" with ctrl + alt + K hotkey
const input = 'Jalebi Fafda & Sambharo';
const e = makeLinkHotKeyEvent(input, 12, 12);
const instance = shallowRender(generateInstance(input));
let selectionStart = -1;
let selectionEnd = -1;
const setSelectionRange = jest.fn((start, end) => {
selectionStart = start;
selectionEnd = end;
});
initRefs(instance, setSelectionRange);
find(instance).props().onKeyDown?.(e);
find(instance).props().handleKeyDown?.(e);
expect(getValue(instance)).toBe('Jalebi [Fafda](url) & Sambharo');
expect(setSelectionRange).toHaveBeenCalled();
expect(selectionStart).toBe(15);
expect(selectionEnd).toBe(18);
});
test('component adds link markdown when cursor is at the end of line', () => {
// Cursor is after "Sambharo" with ctrl + alt + K hotkey
const input = 'Jalebi Fafda & Sambharo';
const e = makeLinkHotKeyEvent(input, 23, 23);
const instance = shallowRender(generateInstance(input));
let selectionStart = -1;
let selectionEnd = -1;
const setSelectionRange = jest.fn((start, end) => {
selectionStart = start;
selectionEnd = end;
});
initRefs(instance, setSelectionRange);
find(instance).props().onKeyDown?.(e);
find(instance).props().handleKeyDown?.(e);
expect(getValue(instance)).toBe('Jalebi Fafda & Sambharo [](url)');
expect(setSelectionRange).toHaveBeenCalled();
expect(selectionStart).toBe(25);
expect(selectionEnd).toBe(25);
});
test('component removes link markdown', () => {
// "Fafda" is selected with ctrl + alt + K hotkey
const input = 'Jalebi [Fafda](url) & Sambharo';
const e = makeLinkHotKeyEvent(input, 8, 13);
const instance = shallowRender(generateInstance(input));
let selectionStart = -1;
let selectionEnd = -1;
const setSelectionRange = jest.fn((start, end) => {
selectionStart = start;
selectionEnd = end;
});
initRefs(instance, setSelectionRange);
find(instance).props().onKeyDown?.(e);
find(instance).props().handleKeyDown?.(e);
expect(getValue(instance)).toBe('Jalebi Fafda & Sambharo');
expect(setSelectionRange).toHaveBeenCalled();
expect(selectionStart).toBe(7);
expect(selectionEnd).toBe(12);
});
}

Просмотреть файл

@@ -13,11 +13,12 @@ import type {DeepPartial} from '@mattermost/types/utilities';
import configureStore from 'store';
import WebSocketClient from 'client/web_websocket_client';
import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
import mockStore from 'tests/test_store';
import {WebSocketContext} from 'utils/use_websocket';
import type {GlobalState} from 'types/store';
export * from '@testing-library/react';
export {userEvent};
@@ -63,7 +64,9 @@ export const renderWithFullContext = (component: React.ReactNode | React.ReactNo
<IntlProvider
locale={renderState.locale}
>
{renderState.component}
<WebSocketContext.Provider value={WebSocketClient}>
{renderState.component}
</WebSocketContext.Provider>
</IntlProvider>
</Router>
</Provider>

Просмотреть файл

@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locations} from './constants';
import {execCommandInsertText} from './exec_commands';
import {
parseHtmlTable,
getHtmlTable,
@@ -10,6 +12,7 @@ import {
isTextUrl,
hasPlainText,
createFileFromClipboardDataItem,
pasteHandler,
} from './paste';
const validClipboardData: any = {
@@ -231,6 +234,79 @@ describe('isTextUrl', () => {
});
});
jest.mock('utils/exec_commands', () => ({
execCommandInsertText: jest.fn(),
}));
describe('pasteHandler', () => {
const testCases = [
{
testName: 'should be able to format a pasted markdown table',
clipboardData: {
items: [1],
types: ['text/html'],
getData: () => {
return '<table><tr><th>test</th><th>test</th></tr><tr><td>test</td><td>test</td></tr></table>';
},
},
expectedMarkdown: '| test | test |\n| --- | --- |\n| test | test |',
},
{
testName: 'should be able to format a pasted markdown table without headers',
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>';
},
},
expectedMarkdown: '| test | test |\n| --- | --- |\n| test | test |\n',
},
{
testName: 'should be able to format a pasted hyperlink',
clipboardData: {
items: [1],
types: ['text/html'],
getData: () => {
return '<a href="https://test.domain">link text</a>';
},
},
expectedMarkdown: '[link text](https://test.domain)',
},
{
testName: 'should be able to format a github codeblock (pasted as a table)',
clipboardData: {
items: [1],
types: ['text/plain', 'text/html'],
getData: (type: string) => {
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>';
},
},
expectedMarkdown: "```\n// a javascript codeblock example\nif (1 > 0) {\n return 'condition is true';\n}\n```",
},
];
for (const tc of testCases) {
it(tc.testName, () => {
const location = Locations.RHS_COMMENT;
const event: any = {
target: {
id: 'reply_textbox',
},
preventDefault: jest.fn(),
clipboardData: tc.clipboardData,
};
pasteHandler(event, location, '', false, 0);
expect(execCommandInsertText).toHaveBeenCalledWith(tc.expectedMarkdown);
});
}
});
describe('hasPlainText', () => {
test('Should return true when clipboard data has plain text', () => {
const clipboardData = {

Просмотреть файл

@@ -1,6 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {isNil} from 'lodash';
import type {TextboxElement} from 'components/textbox';
import {Locations} from 'utils/constants';
import {execCommandInsertText} from 'utils/exec_commands';
import {DEFAULT_PLACEHOLDER_URL} from 'utils/markdown/apply_markdown';
import {splitMessageBasedOnCaretPosition, splitMessageBasedOnTextSelection} from 'utils/post_utils';
import turndownService from 'utils/turndown';
@@ -154,6 +160,43 @@ export function formatMarkdownLinkMessage({message, clipboardData, selectionStar
return markdownLink;
}
export function pasteHandler(event: ClipboardEvent, location: string, message: string, isNonFormattedPaste?: boolean, caretPosition?: number) {
const {clipboardData, target} = event;
const textboxId = location === Locations.RHS_COMMENT ? 'reply_textbox' : 'post_textbox';
if (!clipboardData || !clipboardData.items || !target || (target as TextboxElement)?.id !== textboxId) {
return;
}
const {selectionStart, selectionEnd} = target as TextboxElement;
const hasSelection = !isNil(selectionStart) && !isNil(selectionEnd) && selectionStart < selectionEnd;
const hasTextUrl = isTextUrl(clipboardData);
const hasHTMLLinks = !isNonFormattedPaste && hasHtmlLink(clipboardData);
const htmlTable = getHtmlTable(clipboardData);
const shouldApplyLinkMarkdown = hasSelection && hasTextUrl;
const shouldApplyGithubCodeBlock = htmlTable && isGitHubCodeBlock(htmlTable.className);
if (!htmlTable && !hasHTMLLinks && !shouldApplyLinkMarkdown) {
return;
}
event.preventDefault();
// 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, caretPosition);
execCommandInsertText(formattedMarkdown);
}
}
export function createFileFromClipboardDataItem(item: DataTransferItem, fileNamePrefixIfNoName: string): File | null {
const file = item.getAsFile();