Compare commits

...
Author SHA1 Message Date
sriram veeraghanta 8b6a0915ee feat: comment submit shortcut preference
Add a user profile preference to choose between Enter and Mod+Enter
for submitting comments across the web and space apps. Wires the new
preference through the Profile model, types, utils, editor extension,
and i18n namespace, and exposes a selector in profile preferences.
2026-05-15 02:25:27 +05:30
37 changed files with 342 additions and 37 deletions
@@ -0,0 +1,21 @@
# Generated for comment_submit_shortcut user preference
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0121_alter_estimate_type"),
]
operations = [
migrations.AddField(
model_name="profile",
name="comment_submit_shortcut",
field=models.CharField(
choices=[("enter", "Enter"), ("mod_enter", "Mod+Enter")],
default="enter",
max_length=20,
),
),
]
+7
View File
@@ -210,6 +210,10 @@ class Profile(TimeAuditModel):
FULL = "full", "Full"
COMPACT = "compact", "Compact"
class CommentSubmitShortcut(models.TextChoices):
ENTER = "enter", "Enter"
MOD_ENTER = "mod_enter", "Mod+Enter"
START_OF_THE_WEEK_CHOICES = (
(SUNDAY, "Sunday"),
(MONDAY, "Monday"),
@@ -243,6 +247,9 @@ class Profile(TimeAuditModel):
max_length=255, choices=NotificationViewMode.choices, default=NotificationViewMode.FULL
)
is_smooth_cursor_enabled = models.BooleanField(default=False)
comment_submit_shortcut = models.CharField(
max_length=20, choices=CommentSubmitShortcut.choices, default=CommentSubmitShortcut.ENTER
)
# mobile
is_mobile_onboarded = models.BooleanField(default=False)
mobile_onboarding_step = models.JSONField(default=get_mobile_default_onboarding)
@@ -13,6 +13,7 @@ import { cn, isCommentEmpty } from "@plane/utils";
// helpers
import { getEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useUserProfile } from "@/hooks/store/use-user-profile";
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
// plane web imports
import { useEditorFlagging } from "@/hooks/use-editor-flagging";
@@ -57,6 +58,9 @@ export const LiteTextEditor = React.forwardRef(function LiteTextEditor(
}
// derived values
const isEmpty = isCommentEmpty(props.initialValue);
const {
data: { comment_submit_shortcut },
} = useUserProfile();
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
const { liteText: liteTextEditorExtensions } = useEditorFlagging(anchor);
// parse content
@@ -80,6 +84,7 @@ export const LiteTextEditor = React.forwardRef(function LiteTextEditor(
mentionHandler={{
renderComponent: (props) => <EditorMentionsRoot {...props} />,
}}
submitShortcut={comment_submit_shortcut}
extendedEditorProps={{}}
{...rest}
// overriding the containerClassName to add relative class passed
+1
View File
@@ -59,6 +59,7 @@ export class ProfileStore implements IProfileStore {
updated_at: "",
language: "",
start_of_the_week: EStartOfTheWeek.SUNDAY,
comment_submit_shortcut: "enter",
};
// services
@@ -11,9 +11,11 @@ import type { EditorRefApi } from "@plane/editor";
import { CheckIcon, CloseIcon } from "@plane/propel/icons";
// plane imports
import type { TCommentsOperations, TIssueComment } from "@plane/types";
import { cn, isCommentEmpty } from "@plane/utils";
import { cn, isCommentEmpty, isCommentSubmissionValid } from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor/lite-text";
// hooks
import { useUserProfile } from "@/hooks/store/user";
type Props = {
activityOperations: TCommentsOperations;
@@ -39,6 +41,10 @@ export const CommentCardEditForm = observer(function CommentCardEditForm(props:
} = props;
// refs
const editorRef = useRef<EditorRefApi>(null);
// store hooks
const {
data: { comment_submit_shortcut },
} = useUserProfile();
// form info
const {
formState: { isSubmitting },
@@ -77,7 +83,16 @@ export const CommentCardEditForm = observer(function CommentCardEditForm(props:
<form className="flex flex-col gap-2">
<div
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty) handleSubmit(onEnter)(e);
if (
isCommentSubmissionValid({
e,
shortcut: comment_submit_shortcut,
isEmpty,
isSubmitting,
isEditorReadyToDiscard: !!editorRef.current?.isEditorReadyToDiscard(),
})
)
handleSubmit(onEnter)(e);
}}
>
<LiteTextEditor
@@ -11,10 +11,11 @@ import { useForm, Controller } from "react-hook-form";
import { EIssueCommentAccessSpecifier } from "@plane/constants";
import type { EditorRefApi } from "@plane/editor";
import type { TIssueComment, TCommentsOperations } from "@plane/types";
import { cn, isCommentEmpty } from "@plane/utils";
import { cn, isCommentEmpty, isCommentSubmissionValid } from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor/lite-text";
// hooks
import { useUserProfile } from "@/hooks/store/user";
import { useWorkspace } from "@/hooks/store/use-workspace";
// services
import { FileService } from "@/services/file.service";
@@ -46,6 +47,9 @@ export const CommentCreate = observer(function CommentCreate(props: TCommentCrea
const editorRef = useRef<EditorRefApi>(null);
// store hooks
const workspaceStore = useWorkspace();
const {
data: { comment_submit_shortcut },
} = useUserProfile();
// derived values
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug)?.id as string;
// form info
@@ -95,13 +99,13 @@ export const CommentCreate = observer(function CommentCreate(props: TCommentCrea
className={cn("sticky bottom-0 z-[4] bg-surface-1 sm:static")}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!e.shiftKey &&
!e.ctrlKey &&
!e.metaKey &&
!isEmpty &&
!isSubmitting &&
editorRef.current?.isEditorReadyToDiscard()
isCommentSubmissionValid({
e,
shortcut: comment_submit_shortcut,
isEmpty,
isSubmitting,
isEditorReadyToDiscard: !!editorRef.current?.isEditorReadyToDiscard(),
})
)
handleSubmit(onSubmit)(e);
}}
@@ -19,6 +19,7 @@ import { IssueCommentToolbar } from "@/components/editor/lite-text/toolbar";
// hooks
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
import { useMember } from "@/hooks/store/use-member";
import { useUserProfile } from "@/hooks/store/user";
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
@@ -95,6 +96,9 @@ export const LiteTextEditor = React.forwardRef(function LiteTextEditor(
});
// store hooks
const { getUserDetails } = useMember();
const {
data: { comment_submit_shortcut },
} = useUserProfile();
// parse content
const { getEditorMetaData } = useParseEditorContent({
projectId,
@@ -168,6 +172,7 @@ export const LiteTextEditor = React.forwardRef(function LiteTextEditor(
"p-2": !editable,
})}
extendedEditorProps={{}}
submitShortcut={comment_submit_shortcut}
editorClassName={editorClassName}
{...rest}
/>
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { CommentSubmitShortcut } from "@plane/types";
import { CustomSelect } from "@plane/ui";
import { getIsMac } from "@plane/utils";
// components
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
import { useUserProfile } from "@/hooks/store/user";
const isMac = getIsMac();
const COMMENT_SUBMIT_SHORTCUT_OPTIONS: { value: CommentSubmitShortcut; label: string }[] = [
{ value: "enter", label: "Enter" },
{ value: "mod_enter", label: isMac ? "⌘ + Enter" : "Ctrl + Enter" },
];
export const CommentShortcutSelector = observer(function CommentShortcutSelector() {
// store hooks
const {
data: { comment_submit_shortcut },
updateUserProfile,
} = useUserProfile();
// derived values
const selectedLabel = COMMENT_SUBMIT_SHORTCUT_OPTIONS.find((o) => o.value === comment_submit_shortcut)?.label;
// translation
const { t } = useTranslation();
return (
<SettingsControlItem
title={t("comment_submit_shortcut.label")}
description={t("comment_submit_shortcut.description")}
control={
<CustomSelect
value={comment_submit_shortcut}
label={selectedLabel}
onChange={(value: CommentSubmitShortcut) => {
updateUserProfile({ comment_submit_shortcut: value });
}}
buttonClassName="border border-subtle-1"
input
placement="bottom-end"
>
{COMMENT_SUBMIT_SHORTCUT_OPTIONS.map((option) => (
<CustomSelect.Option key={option.value} value={option.value}>
{option.label}
</CustomSelect.Option>
))}
</CustomSelect>
}
/>
);
});
@@ -7,6 +7,8 @@
import { observer } from "mobx-react";
// components
import { ThemeSwitcher } from "@/plane-web/components/preferences/theme-switcher";
// local imports
import { CommentShortcutSelector } from "./comment-shortcut-selector";
export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() {
return (
@@ -18,6 +20,7 @@ export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSe
description: "select_or_customize_your_interface_color_scheme",
}}
/>
<CommentShortcutSelector />
</div>
);
});
@@ -66,6 +66,7 @@ export class ProfileStore implements IUserProfileStore {
updated_at: "",
language: "",
start_of_the_week: EStartOfTheWeek.SUNDAY,
comment_submit_shortcut: "enter",
};
// services
@@ -13,17 +13,17 @@ import { EnterKeyExtension } from "@/extensions";
import type { EditorRefApi, ILiteTextEditorProps } from "@/types";
function LiteTextEditor(props: ILiteTextEditorProps) {
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props;
const { onEnterKeyPress, submitShortcut, disabledExtensions, extensions: externalExtensions = [] } = props;
const extensions = useMemo(() => {
const resolvedExtensions = [...externalExtensions];
if (!disabledExtensions?.includes("enter-key")) {
resolvedExtensions.push(EnterKeyExtension(onEnterKeyPress));
resolvedExtensions.push(EnterKeyExtension({ onEnterKeyPress, submitShortcut }));
}
return resolvedExtensions;
}, [externalExtensions, disabledExtensions, onEnterKeyPress]);
}, [externalExtensions, disabledExtensions, onEnterKeyPress, submitShortcut]);
return <EditorWrapper {...props} extensions={extensions} />;
}
@@ -4,35 +4,61 @@
* See the LICENSE file for details.
*/
import type { Editor } from "@tiptap/core";
import { Extension } from "@tiptap/core";
// plane imports
import type { CommentSubmitShortcut } from "@plane/types";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
Extension.create({
type EnterKeyExtensionArgs = {
onEnterKeyPress?: () => void;
submitShortcut?: CommentSubmitShortcut;
};
export const EnterKeyExtension = ({ onEnterKeyPress, submitShortcut = "enter" }: EnterKeyExtensionArgs) => {
const newLine = ({ editor }: { editor: Editor }) =>
editor.commands.first(({ commands }) => [
() => commands.newlineInCode(),
() => commands.splitListItem(CORE_EXTENSIONS.LIST_ITEM),
() => commands.splitListItem(CORE_EXTENSIONS.TASK_ITEM),
() => commands.createParagraphNear(),
() => commands.liftEmptyBlock(),
() => commands.splitBlock(),
]);
const submit = (editor: Editor) => {
const activeDropbarExtensions = editor.storage.utility?.activeDropbarExtensions ?? [];
if (activeDropbarExtensions.length === 0) {
onEnterKeyPress?.();
return true;
}
return false;
};
return Extension.create({
name: CORE_EXTENSIONS.ENTER_KEY,
addKeyboardShortcuts(this) {
return {
Enter: () => {
const { activeDropbarExtensions } = this.editor.storage.utility;
if (activeDropbarExtensions.length === 0) {
onEnterKeyPress?.();
return true;
}
return false;
},
"Shift-Enter": ({ editor }) =>
editor.commands.first(({ commands }) => [
() => commands.newlineInCode(),
() => commands.splitListItem(CORE_EXTENSIONS.LIST_ITEM),
() => commands.splitListItem(CORE_EXTENSIONS.TASK_ITEM),
() => commands.createParagraphNear(),
() => commands.liftEmptyBlock(),
() => commands.splitBlock(),
]),
};
if (submitShortcut === "enter") {
return {
// Shift+Enter always inserts a new line (its default behavior)
"Shift-Enter": newLine,
// Enter submits
Enter: () => submit(this.editor),
// Mod+Enter always inserts a line-break (its default behavior)
"Mod-Enter": () => false,
};
} else {
return {
// Shift+Enter always inserts a line-break (its default behavior)
"Shift-Enter": () => false,
// Enter always inserts a new line (its default behavior)
Enter: () => false,
// Mod+Enter submits
"Mod-Enter": () => submit(this.editor),
};
}
},
});
};
+4 -1
View File
@@ -10,6 +10,7 @@ import type { Selection } from "@tiptap/pm/state";
import type { EditorProps, EditorView } from "@tiptap/pm/view";
import type { NodeViewProps as TNodeViewProps } from "@tiptap/react";
// plane imports
import type { CommentSubmitShortcut } from "@plane/types";
import type { TCustomComponentsMetaData } from "@plane/utils";
// extension types
import type { TTextAlign } from "@/extensions";
@@ -184,7 +185,9 @@ export type IEditorProps = {
workItemIdentifier?: string | null;
};
export type ILiteTextEditorProps = IEditorProps;
export type ILiteTextEditorProps = IEditorProps & {
submitShortcut?: CommentSubmitShortcut;
};
export type IRichTextEditorProps = IEditorProps & {
dragDropEnabled?: boolean;
@@ -20,6 +20,7 @@ export const NAMESPACES = [
"notification",
"page",
"power-k",
"profile-preferences",
"project",
"project-settings",
"settings",
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "Klávesová zkratka pro odeslání komentáře",
"description": "Vyberte klávesovou zkratku pro odesílání komentářů."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "Tastenkürzel zum Senden von Kommentaren",
"description": "Wählen Sie die Tastenkombination zum Absenden von Kommentaren."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "Comment submit shortcut",
"description": "Choose the keyboard shortcut to submit comments."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "Atajo para enviar comentarios",
"description": "Elige el atajo de teclado para enviar comentarios."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "Raccourci pour soumettre des commentaires",
"description": "Choisissez le raccourci clavier pour soumettre des commentaires."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "Pintasan keyboard untuk mengirim komentar",
"description": "Pilih pintasan keyboard untuk mengirim komentar."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "Scorciatoia per inviare commenti",
"description": "Scegli la scorciatoia da tastiera per inviare commenti."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "コメント送信のショートカット",
"description": "コメントを送信するキーボードショートカットを選択してください。"
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "댓글 제출 단축키",
"description": "댓글을 제출할 키보드 단축키를 선택하세요."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "Skrót klawiszowy do wysyłania komentarzy",
"description": "Wybierz skrót klawiszowy do wysyłania komentarzy."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "Atalho para enviar comentários",
"description": "Escolha o atalho de teclado para enviar comentários."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "Comandă rapidă pentru trimiterea comentariilor",
"description": "Alegeți comanda rapidă de la tastatură pentru a trimite comentarii."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "Горячая клавиша для отправки комментариев",
"description": "Выберите сочетание клавиш для отправки комментариев."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "Klávesová skratka na odoslanie komentára",
"description": "Vyberte klávesovú skratku na odosielanie komentárov."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "Yorum gönderme kısayolu",
"description": "Yorum göndermek için klavye kısayolunu seçin."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "Гарячі клавіші для надсилання коментарів",
"description": "Виберіть комбінацію клавіш для надсилання коментарів."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "Phím tắt gửi bình luận",
"description": "Chọn phím tắt bàn phím để gửi bình luận."
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "评论提交快捷键",
"description": "选择提交评论的键盘快捷键。"
}
}
@@ -0,0 +1,6 @@
{
"comment_submit_shortcut": {
"label": "留言提交快捷鍵",
"description": "選擇提交留言的鍵盤快捷鍵。"
}
}
+3
View File
@@ -59,6 +59,8 @@ export interface IUserAccount {
updated_at: Date;
}
export type CommentSubmitShortcut = "enter" | "mod_enter";
export type TUserProfile = {
id: string | undefined;
user: string | undefined;
@@ -79,6 +81,7 @@ export type TUserProfile = {
has_billing_address: boolean;
has_marketing_email_consent: boolean;
language: string;
comment_submit_shortcut: CommentSubmitShortcut;
created_at: Date | string;
updated_at: Date | string;
start_of_the_week: EStartOfTheWeek;
+33
View File
@@ -0,0 +1,33 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { CommentSubmitShortcut } from "@plane/types";
type IsCommentSubmissionValidArgs = {
e: React.KeyboardEvent;
shortcut: CommentSubmitShortcut;
isEmpty: boolean;
isSubmitting: boolean;
isEditorReadyToDiscard: boolean;
};
/**
* Returns true if the keyboard event matches the configured comment submit shortcut and-
* - the comment is not empty,
* - the editor is ready to discard,
* - the form is not submitting.
*/
export const isCommentSubmissionValid = ({
e,
shortcut = "enter",
isEmpty,
isSubmitting,
isEditorReadyToDiscard,
}: IsCommentSubmissionValidArgs): boolean => {
if (e.key !== "Enter" || e.shiftKey) return false;
const isShortcutValid = shortcut === "enter" ? !e.ctrlKey && !e.metaKey : e.ctrlKey || e.metaKey;
return isShortcutValid && !isEmpty && !isSubmitting && isEditorReadyToDiscard;
};
+2
View File
@@ -121,3 +121,5 @@ export const isComplete = <T>(obj: CompleteOrEmpty<T>): obj is T => {
};
export const convertRemToPixel = (rem: number): number => rem * 0.9 * 16;
export const getIsMac = () => typeof window !== "undefined" && navigator.userAgent.indexOf("Mac") !== -1;
+1
View File
@@ -9,6 +9,7 @@ export * from "./attachment";
export * from "./auth";
export * from "./calendar";
export * from "./color";
export * from "./comment";
export * from "./common";
export * from "./cycle";
export * from "./datetime";