feat: implement platform-aware keyboard shortcuts for sidebar and toolbar actions, enhancing user experience

This commit is contained in:
Anish Sarkar 2026-02-18 00:26:18 +05:30
parent baa02c50e4
commit 7b44dd58f5
6 changed files with 113 additions and 15 deletions

View file

@ -1,8 +1,8 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { MarkdownPlugin, remarkMdx } from "@platejs/markdown";
import { Plate, usePlateEditor } from "platejs/react";
import { createPlatePlugin, Key, Plate, usePlateEditor } from "platejs/react";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
@ -69,6 +69,31 @@ export function PlateEditor({
}: PlateEditorProps) {
const lastMarkdownRef = useRef(markdown);
// Keep a stable ref to the latest onSave callback so the plugin shortcut
// always calls the most recent version without re-creating the editor.
const onSaveRef = useRef(onSave);
useEffect(() => {
onSaveRef.current = onSave;
}, [onSave]);
// Stable Plate plugin for ⌘+S / Ctrl+S save shortcut
const SaveShortcutPlugin = useMemo(
() =>
createPlatePlugin({
key: "save-shortcut",
shortcuts: {
save: {
keys: [[Key.Mod, "s"]],
handler: () => {
onSaveRef.current?.();
},
preventDefault: true,
},
},
}),
[]
);
// When readOnly is forced, always start in readOnly.
// Otherwise, respect defaultEditing to decide initial mode.
// The user can still toggle between editing/viewing via ModeToolbarButton.
@ -89,6 +114,7 @@ export function PlateEditor({
...FloatingToolbarKit,
...AutoformatKit,
...DndKit,
SaveShortcutPlugin,
MarkdownPlugin.configure({
options: {
remarkPlugins: [remarkGfm, remarkMath, remarkMdx],

View file

@ -40,10 +40,10 @@ export function useSidebarState(defaultCollapsed = false): UseSidebarStateReturn
setIsCollapsed(!isCollapsed);
}, [isCollapsed, setIsCollapsed]);
// Keyboard shortcut: Cmd/Ctrl + B
// Keyboard shortcut: Cmd/Ctrl + \
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "b" && (event.metaKey || event.ctrlKey)) {
if (event.key === "\\" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleCollapsed();
}

View file

@ -4,6 +4,7 @@ import { PanelLeft, PanelLeftClose } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
interface SidebarCollapseButtonProps {
isCollapsed: boolean;
@ -17,6 +18,7 @@ export function SidebarCollapseButton({
disableTooltip = false,
}: SidebarCollapseButtonProps) {
const t = useTranslations("sidebar");
const { shortcut } = usePlatformShortcut();
const button = (
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
@ -33,7 +35,9 @@ export function SidebarCollapseButton({
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side={isCollapsed ? "right" : "bottom"}>
{isCollapsed ? `${t("expand_sidebar")} (⌘B)` : `${t("collapse_sidebar")} (⌘B)`}
{isCollapsed
? `${t("expand_sidebar")} ${shortcut("Mod", "\\")}`
: `${t("collapse_sidebar")} ${shortcut("Mod", "\\")}`}
</TooltipContent>
</Tooltip>
);

View file

@ -17,6 +17,7 @@ import { KEYS } from "platejs";
import { useEditorReadOnly, useEditorRef } from "platejs/react";
import { useEditorSave } from "@/components/editor/editor-save-context";
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
import { Spinner } from "@/components/ui/spinner";
import { InsertToolbarButton } from "./insert-toolbar-button";
@ -30,6 +31,7 @@ export function FixedToolbarButtons() {
const readOnly = useEditorReadOnly();
const editor = useEditorRef();
const { onSave, hasUnsavedChanges, isSaving, canToggleMode } = useEditorSave();
const { shortcut } = usePlatformShortcut();
return (
<div className="flex w-full items-center">
@ -39,7 +41,7 @@ export function FixedToolbarButtons() {
<>
<ToolbarGroup>
<ToolbarButton
tooltip="Undo (⌘+Z)"
tooltip={`Undo ${shortcut("Mod", "Z")}`}
onClick={() => {
editor.undo();
editor.tf.focus();
@ -49,7 +51,7 @@ export function FixedToolbarButtons() {
</ToolbarButton>
<ToolbarButton
tooltip="Redo (⌘+⇧+Z)"
tooltip={`Redo ${shortcut("Mod", "Shift", "Z")}`}
onClick={() => {
editor.redo();
editor.tf.focus();
@ -65,27 +67,36 @@ export function FixedToolbarButtons() {
</ToolbarGroup>
<ToolbarGroup>
<MarkToolbarButton nodeType={KEYS.bold} tooltip="Bold (⌘+B)">
<MarkToolbarButton nodeType={KEYS.bold} tooltip={`Bold ${shortcut("Mod", "B")}`}>
<BoldIcon />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.italic} tooltip="Italic (⌘+I)">
<MarkToolbarButton nodeType={KEYS.italic} tooltip={`Italic ${shortcut("Mod", "I")}`}>
<ItalicIcon />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.underline} tooltip="Underline (⌘+U)">
<MarkToolbarButton
nodeType={KEYS.underline}
tooltip={`Underline ${shortcut("Mod", "U")}`}
>
<UnderlineIcon />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.strikethrough} tooltip="Strikethrough (⌘+⇧+M)">
<MarkToolbarButton
nodeType={KEYS.strikethrough}
tooltip={`Strikethrough ${shortcut("Mod", "Shift", "X")}`}
>
<StrikethroughIcon />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.code} tooltip="Code (⌘+E)">
<MarkToolbarButton nodeType={KEYS.code} tooltip={`Code ${shortcut("Mod", "E")}`}>
<Code2Icon />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.highlight} tooltip="Highlight (⌘+⇧+H)">
<MarkToolbarButton
nodeType={KEYS.highlight}
tooltip={`Highlight ${shortcut("Mod", "Shift", "H")}`}
>
<HighlighterIcon />
</MarkToolbarButton>
</ToolbarGroup>
@ -103,7 +114,7 @@ export function FixedToolbarButtons() {
{!readOnly && onSave && hasUnsavedChanges && (
<ToolbarGroup>
<ToolbarButton
tooltip={isSaving ? "Saving..." : "Save (⌘+S)"}
tooltip={isSaving ? "Saving..." : `Save ${shortcut("Mod", "S")}`}
onClick={onSave}
disabled={isSaving}
className="bg-primary text-primary-foreground hover:bg-primary/90"

View file

@ -24,7 +24,7 @@ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
const SIDEBAR_KEYBOARD_SHORTCUT = "\\";
type SidebarContext = {
state: "expanded" | "collapsed";