mirror of
https://github.com/vector-im/element-web.git
synced 2025-08-06 22:37:10 +02:00
* Add quote functionality to MessageContextMenu (#29893) * Remove unused import of getSelectedText from strings utility in EventTile component * Add space after quoted text in ComposerInsert action * Add space after quoted text in MessageContextMenu test * add new line before and after the formated text
This commit is contained in:
parent
084f447c6e
commit
3e11a62a3f
@ -183,6 +183,30 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the current selection is entirely within a single "mx_MTextBody" element.
|
||||
*/
|
||||
private isSelectionWithinSingleTextBody(): boolean {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return false;
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
function getParentByClass(node: Node | null, className: string): HTMLElement | null {
|
||||
while (node) {
|
||||
if (node instanceof HTMLElement && node.classList.contains(className)) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const startTextBody = getParentByClass(range.startContainer, "mx_MTextBody");
|
||||
const endTextBody = getParentByClass(range.endContainer, "mx_MTextBody");
|
||||
|
||||
return !!startTextBody && startTextBody === endTextBody;
|
||||
}
|
||||
|
||||
private onResendReactionsClick = (): void => {
|
||||
for (const reaction of this.getUnsentReactions()) {
|
||||
Resend.resend(MatrixClientPeg.safeGet(), reaction);
|
||||
@ -279,6 +303,24 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
private onQuoteClick = (): void => {
|
||||
const selectedText = getSelectedText();
|
||||
if (selectedText) {
|
||||
// Format as markdown quote
|
||||
const quotedText = selectedText
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => `> ${line}`)
|
||||
.join("\n");
|
||||
dis.dispatch({
|
||||
action: Action.ComposerInsert,
|
||||
text: "\n" + quotedText + "\n\n ",
|
||||
timelineRenderingType: this.context.timelineRenderingType,
|
||||
});
|
||||
}
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
private onEditClick = (): void => {
|
||||
editEvent(
|
||||
MatrixClientPeg.safeGet(),
|
||||
@ -549,8 +591,10 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedText = getSelectedText();
|
||||
|
||||
let copyButton: JSX.Element | undefined;
|
||||
if (rightClick && getSelectedText()) {
|
||||
if (rightClick && selectedText) {
|
||||
copyButton = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_MessageContextMenu_iconCopy"
|
||||
@ -561,6 +605,18 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||
);
|
||||
}
|
||||
|
||||
let quoteButton: JSX.Element | undefined;
|
||||
if (rightClick && selectedText && selectedText.trim().length > 0 && this.isSelectionWithinSingleTextBody()) {
|
||||
quoteButton = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_MessageContextMenu_iconQuote"
|
||||
label={_t("action|quote")}
|
||||
triggerOnMouseDown={true}
|
||||
onClick={this.onQuoteClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let editButton: JSX.Element | undefined;
|
||||
if (rightClick && canEditContent(cli, mxEvent)) {
|
||||
editButton = (
|
||||
@ -630,10 +686,11 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||
}
|
||||
|
||||
let nativeItemsList: JSX.Element | undefined;
|
||||
if (copyButton || copyLinkButton) {
|
||||
if (copyButton || quoteButton || copyLinkButton) {
|
||||
nativeItemsList = (
|
||||
<IconizedContextMenuOptionList>
|
||||
{copyButton}
|
||||
{quoteButton}
|
||||
{copyLinkButton}
|
||||
</IconizedContextMenuOptionList>
|
||||
);
|
||||
|
@ -64,7 +64,7 @@ import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { copyPlaintext, getSelectedText } from "../../../utils/strings";
|
||||
import { copyPlaintext } from "../../../utils/strings";
|
||||
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
|
||||
import RedactedBody from "../messages/RedactedBody";
|
||||
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
@ -840,10 +840,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
// Electron layer (webcontents-handler.ts)
|
||||
if (clickTarget instanceof HTMLImageElement) return;
|
||||
|
||||
// Return if we're in a browser and click either an a tag or we have
|
||||
// selected text, as in those cases we want to use the native browser
|
||||
// menu
|
||||
if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && (getSelectedText() || anchorElement)) return;
|
||||
// Return if we're in a browser and click either an a tag, as in those cases we want to use the native browser menu
|
||||
if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && anchorElement) return;
|
||||
|
||||
// We don't want to show the menu when editing a message
|
||||
if (this.props.editState) return;
|
||||
|
@ -356,6 +356,226 @@ describe("MessageContextMenu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("quote button", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows quote button when selection is inside one MTextBody and getSelectedText returns text", () => {
|
||||
mocked(getSelectedText).mockReturnValue("quoted text");
|
||||
const isSelectionWithinSingleTextBody = jest
|
||||
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
|
||||
.mockReturnValue(true);
|
||||
|
||||
createRightClickMenuWithContent(createMessageEventContent("hello"));
|
||||
const quoteButton = document.querySelector('li[aria-label="Quote"]');
|
||||
expect(quoteButton).toBeTruthy();
|
||||
|
||||
isSelectionWithinSingleTextBody.mockRestore();
|
||||
});
|
||||
|
||||
it("does not show quote button when getSelectedText returns empty", () => {
|
||||
mocked(getSelectedText).mockReturnValue("");
|
||||
const isSelectionWithinSingleTextBody = jest
|
||||
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
|
||||
.mockReturnValue(true);
|
||||
|
||||
createRightClickMenuWithContent(createMessageEventContent("hello"));
|
||||
const quoteButton = document.querySelector('li[aria-label="Quote"]');
|
||||
expect(quoteButton).toBeFalsy();
|
||||
|
||||
isSelectionWithinSingleTextBody.mockRestore();
|
||||
});
|
||||
|
||||
it("does not show quote button when selection is not inside one MTextBody", () => {
|
||||
mocked(getSelectedText).mockReturnValue("quoted text");
|
||||
const isSelectionWithinSingleTextBody = jest
|
||||
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
|
||||
.mockReturnValue(false);
|
||||
|
||||
createRightClickMenuWithContent(createMessageEventContent("hello"));
|
||||
const quoteButton = document.querySelector('li[aria-label="Quote"]');
|
||||
expect(quoteButton).toBeFalsy();
|
||||
|
||||
isSelectionWithinSingleTextBody.mockRestore();
|
||||
});
|
||||
|
||||
it("dispatches ComposerInsert with quoted text when quote button is clicked", () => {
|
||||
mocked(getSelectedText).mockReturnValue("line1\nline2");
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
const isSelectionWithinSingleTextBody = jest
|
||||
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
|
||||
.mockReturnValue(true);
|
||||
|
||||
createRightClickMenuWithContent(createMessageEventContent("hello"));
|
||||
const quoteButton = document.querySelector('li[aria-label="Quote"]')!;
|
||||
fireEvent.mouseDown(quoteButton);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: Action.ComposerInsert,
|
||||
text: "\n> line1\n> line2\n\n ",
|
||||
}),
|
||||
);
|
||||
|
||||
isSelectionWithinSingleTextBody.mockRestore();
|
||||
});
|
||||
|
||||
it("does not show quote button when getSelectedText returns only whitespace", () => {
|
||||
mocked(getSelectedText).mockReturnValue(" \n\t "); // whitespace only
|
||||
const isSelectionWithinSingleTextBody = jest
|
||||
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
|
||||
.mockReturnValue(true);
|
||||
|
||||
createRightClickMenuWithContent(createMessageEventContent("hello"));
|
||||
const quoteButton = document.querySelector('li[aria-label="Quote"]');
|
||||
expect(quoteButton).toBeFalsy();
|
||||
|
||||
isSelectionWithinSingleTextBody.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSelectionWithinSingleTextBody", () => {
|
||||
let mockGetSelection: jest.SpyInstance;
|
||||
let contextMenuInstance: MessageContextMenu;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockGetSelection = jest.spyOn(window, "getSelection");
|
||||
|
||||
const eventContent = createMessageEventContent("hello");
|
||||
const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent });
|
||||
|
||||
contextMenuInstance = new MessageContextMenu({
|
||||
mxEvent,
|
||||
onFinished: jest.fn(),
|
||||
rightClick: true,
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockGetSelection.mockRestore();
|
||||
});
|
||||
|
||||
it("returns false when there is no selection", () => {
|
||||
mockGetSelection.mockReturnValue(null);
|
||||
|
||||
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when selection has no ranges", () => {
|
||||
mockGetSelection.mockReturnValue({
|
||||
rangeCount: 0,
|
||||
getRangeAt: jest.fn(),
|
||||
} as any);
|
||||
|
||||
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when selection is within a single mx_MTextBody element", () => {
|
||||
// Create a mock MTextBody element
|
||||
const textBodyElement = document.createElement("div");
|
||||
textBodyElement.classList.add("mx_MTextBody");
|
||||
|
||||
// Create mock text nodes within the MTextBody
|
||||
const startTextNode = document.createTextNode("start");
|
||||
const endTextNode = document.createTextNode("end");
|
||||
textBodyElement.appendChild(startTextNode);
|
||||
textBodyElement.appendChild(endTextNode);
|
||||
|
||||
// Create a mock range with the text nodes
|
||||
const mockRange = {
|
||||
startContainer: startTextNode,
|
||||
endContainer: endTextNode,
|
||||
} as unknown as Range;
|
||||
|
||||
mockGetSelection.mockReturnValue({
|
||||
rangeCount: 1,
|
||||
getRangeAt: jest.fn().mockReturnValue(mockRange),
|
||||
} as any);
|
||||
|
||||
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when selection spans multiple mx_MTextBody elements", () => {
|
||||
// Create two different MTextBody elements
|
||||
const textBody1 = document.createElement("div");
|
||||
textBody1.classList.add("mx_MTextBody");
|
||||
const textBody2 = document.createElement("div");
|
||||
textBody2.classList.add("mx_MTextBody");
|
||||
|
||||
const startTextNode = document.createTextNode("start");
|
||||
const endTextNode = document.createTextNode("end");
|
||||
textBody1.appendChild(startTextNode);
|
||||
textBody2.appendChild(endTextNode);
|
||||
|
||||
// Create a mock range spanning different MTextBody elements
|
||||
const mockRange = {
|
||||
startContainer: startTextNode,
|
||||
endContainer: endTextNode,
|
||||
} as unknown as Range;
|
||||
|
||||
mockGetSelection.mockReturnValue({
|
||||
rangeCount: 1,
|
||||
getRangeAt: jest.fn().mockReturnValue(mockRange),
|
||||
} as any);
|
||||
|
||||
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when selection is outside any mx_MTextBody element", () => {
|
||||
// Create regular div elements without mx_MTextBody class
|
||||
const regularDiv1 = document.createElement("div");
|
||||
const regularDiv2 = document.createElement("div");
|
||||
|
||||
const startTextNode = document.createTextNode("start");
|
||||
const endTextNode = document.createTextNode("end");
|
||||
regularDiv1.appendChild(startTextNode);
|
||||
regularDiv2.appendChild(endTextNode);
|
||||
|
||||
// Create a mock range outside MTextBody elements
|
||||
const mockRange = {
|
||||
startContainer: startTextNode,
|
||||
endContainer: endTextNode,
|
||||
} as unknown as Range;
|
||||
|
||||
mockGetSelection.mockReturnValue({
|
||||
rangeCount: 1,
|
||||
getRangeAt: jest.fn().mockReturnValue(mockRange),
|
||||
} as any);
|
||||
|
||||
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when start and end are the same mx_MTextBody element", () => {
|
||||
const textBodyElement = document.createElement("div");
|
||||
textBodyElement.classList.add("mx_MTextBody");
|
||||
|
||||
const textNode = document.createTextNode("same text");
|
||||
textBodyElement.appendChild(textNode);
|
||||
|
||||
// Create a mock range within the same MTextBody element
|
||||
const mockRange = {
|
||||
startContainer: textNode,
|
||||
endContainer: textNode,
|
||||
} as unknown as Range;
|
||||
|
||||
mockGetSelection.mockReturnValue({
|
||||
rangeCount: 1,
|
||||
getRangeAt: jest.fn().mockReturnValue(mockRange),
|
||||
} as any);
|
||||
|
||||
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("right click", () => {
|
||||
it("copy button does work as expected", () => {
|
||||
const text = "hello";
|
||||
|
Loading…
Reference in New Issue
Block a user