Disable RTE formatting buttons when the content contains a slash command (#30802)

* Add ability to disable all formatting buttons

* Create hook to check if the content contains a slash command

* Disable the formatting buttons if the message content contains a slash command

* lint

* typo
This commit is contained in:
David Langley
2025-09-17 12:47:20 +01:00
committed by GitHub
parent 25a8591791
commit db2e958823
6 changed files with 266 additions and 14 deletions

View File

@@ -43,6 +43,7 @@ function Button({ label, keyCombo, onClick, actionState, icon }: ButtonProps): J
element="button"
onClick={onClick as (e: ButtonEvent) => void}
aria-label={label}
disabled={actionState === "disabled"}
className={classNames("mx_FormattingButtons_Button", {
mx_FormattingButtons_active: actionState === "reversed",
mx_FormattingButtons_Button_hover: actionState === "enabled",
@@ -64,55 +65,59 @@ function Button({ label, keyCombo, onClick, actionState, icon }: ButtonProps): J
interface FormattingButtonsProps {
composer: FormattingFunctions;
actionStates: AllActionStates;
/**
* Whether all buttons should be disabled
*/
disabled?: boolean;
}
export function FormattingButtons({ composer, actionStates }: FormattingButtonsProps): JSX.Element {
export function FormattingButtons({ composer, actionStates, disabled }: FormattingButtonsProps): JSX.Element {
const composerContext = useComposerContext();
const isInList = actionStates.unorderedList === "reversed" || actionStates.orderedList === "reversed";
return (
<div className="mx_FormattingButtons">
<Button
actionState={actionStates.bold}
actionState={disabled ? "disabled" : actionStates.bold}
label={_t("composer|format_bold")}
keyCombo={{ ctrlOrCmdKey: true, key: "b" }}
onClick={() => composer.bold()}
icon={<BoldIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.italic}
actionState={disabled ? "disabled" : actionStates.italic}
label={_t("composer|format_italic")}
keyCombo={{ ctrlOrCmdKey: true, key: "i" }}
onClick={() => composer.italic()}
icon={<ItalicIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.underline}
actionState={disabled ? "disabled" : actionStates.underline}
label={_t("composer|format_underline")}
keyCombo={{ ctrlOrCmdKey: true, key: "u" }}
onClick={() => composer.underline()}
icon={<UnderlineIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.strikeThrough}
actionState={disabled ? "disabled" : actionStates.strikeThrough}
label={_t("composer|format_strikethrough")}
onClick={() => composer.strikeThrough()}
icon={<StrikeThroughIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.unorderedList}
actionState={disabled ? "disabled" : actionStates.unorderedList}
label={_t("composer|format_unordered_list")}
onClick={() => composer.unorderedList()}
icon={<BulletedListIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.orderedList}
actionState={disabled ? "disabled" : actionStates.orderedList}
label={_t("composer|format_ordered_list")}
onClick={() => composer.orderedList()}
icon={<NumberedListIcon className="mx_FormattingButtons_Icon" />}
/>
{isInList && (
<Button
actionState={actionStates.indent}
actionState={disabled ? "disabled" : actionStates.indent}
label={_t("composer|format_increase_indent")}
onClick={() => composer.indent()}
icon={<IndentIcon className="mx_FormattingButtons_Icon" />}
@@ -120,33 +125,33 @@ export function FormattingButtons({ composer, actionStates }: FormattingButtonsP
)}
{isInList && (
<Button
actionState={actionStates.unindent}
actionState={disabled ? "disabled" : actionStates.unindent}
label={_t("composer|format_decrease_indent")}
onClick={() => composer.unindent()}
icon={<UnIndentIcon className="mx_FormattingButtons_Icon" />}
/>
)}
<Button
actionState={actionStates.quote}
actionState={disabled ? "disabled" : actionStates.quote}
label={_t("action|quote")}
onClick={() => composer.quote()}
icon={<QuoteIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.inlineCode}
actionState={disabled ? "disabled" : actionStates.inlineCode}
label={_t("composer|format_inline_code")}
keyCombo={{ ctrlOrCmdKey: true, key: "e" }}
onClick={() => composer.inlineCode()}
icon={<InlineCodeIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.codeBlock}
actionState={disabled ? "disabled" : actionStates.codeBlock}
label={_t("composer|format_code_block")}
onClick={() => composer.codeBlock()}
icon={<CodeBlockIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.link}
actionState={disabled ? "disabled" : actionStates.link}
label={_t("composer|format_link")}
onClick={() => openLinkModal(composer, composerContext, actionStates.link === "reversed")}
icon={<LinkIcon className="mx_FormattingButtons_Icon" />}

View File

@@ -25,6 +25,7 @@ import { parsePermalink } from "../../../../../utils/permalinks/Permalinks";
import { isNotNull } from "../../../../../Typeguards";
import { useSettingValue } from "../../../../../hooks/useSettings";
import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx";
import { useContainsCommand } from "../hooks/useContainsCommand.ts";
interface WysiwygComposerProps {
disabled?: boolean;
@@ -83,6 +84,9 @@ export const WysiwygComposer = memo(function WysiwygComposer({
}
}, [onChange, messageContent, disabled]);
// Disable formatting buttons if the message content contains a slash command
const disableFormatting = useContainsCommand(content, room);
useEffect(() => {
function handleClick(e: Event): void {
e.preventDefault();
@@ -124,7 +128,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
handleAtRoomMention={wysiwyg.mentionAtRoom}
handleCommand={wysiwyg.command}
/>
<FormattingButtons composer={wysiwyg} actionStates={actionStates} />
<FormattingButtons composer={wysiwyg} actionStates={actionStates} disabled={disableFormatting} />
<Editor
ref={ref}
disabled={!isReady}

View File

@@ -0,0 +1,53 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import { useEffect, useState, useRef } from "react";
import CommandProvider from "../../../../../autocomplete/CommandProvider";
/**
* A hook which determines if the given content contains a slash command.
* @returns true if the content contains a slash command, false otherwise.
* @param content The content to check for commands.
* @param room The current room.
*/
export function useContainsCommand(content: string | null, room: Room | undefined): boolean {
const [contentContainsCommands, setContentContainsCommands] = useState(false);
const providerRef = useRef<CommandProvider | null>(null);
const currentRoomIdRef = useRef<string | null>(null);
useEffect(() => {
if (!room || !content) {
setContentContainsCommands(false);
return;
}
// Create or reuse CommandProvider for the current room
if (!providerRef.current || currentRoomIdRef.current !== room.roomId) {
providerRef.current = new CommandProvider(room);
currentRoomIdRef.current = room.roomId;
}
const provider = providerRef.current;
provider
.getCompletions(content, { start: 0, end: 0 })
.then((results) => {
if (results.length > 0) {
setContentContainsCommands(true);
} else {
setContentContainsCommands(false);
}
})
.catch(() => {
// If there's an error getting completions, assume no commands
setContentContainsCommands(false);
});
}, [content, room]);
return contentContainsCommands;
}