Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shortcuts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/admin": patch
---

Fixes the content editor's distraction-free mode shortcut so `⌘⇧\` (or `Ctrl+Shift+\`) toggles the mode both in and out, keeps the exit button visible without hovering, and no longer treats `Escape` as an exit trigger.
37 changes: 19 additions & 18 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "@phosphor-icons/react";
import type { Editor } from "@tiptap/react";
import * as React from "react";
import { useHotkeys } from "react-hotkeys-hook";

import type {
BylineCreditInput,
Expand Down Expand Up @@ -884,22 +885,20 @@ export function ContentEditor({
// Distraction-free mode state
const [isDistractionFree, setIsDistractionFree] = React.useState(false);

// Escape exits distraction-free mode
React.useEffect(() => {
if (!isDistractionFree) return;

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (scheduleDialogOpen || publishingMenuOpen) return;
e.preventDefault();
e.stopPropagation();
setIsDistractionFree(false);
}
};

document.addEventListener("keydown", handleKeyDown, { capture: true });
return () => document.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [isDistractionFree, publishingMenuOpen, scheduleDialogOpen]);
// The title advertises ⌘⇧\\ as the shortcut, so register it globally.
// It toggles both into and out of the mode, but is disabled while a
// publishing menu or schedule dialog is open.
const canToggleDistractionFree = !scheduleDialogOpen && !publishingMenuOpen;
useHotkeys(
"mod+shift+\\",
(e) => {
if (!canToggleDistractionFree) return;
e.preventDefault();
setIsDistractionFree((prev) => !prev);
},
{ enableOnFormTags: true, useKey: true },
[canToggleDistractionFree],
);

return (
<form
Expand Down Expand Up @@ -934,12 +933,13 @@ export function ContentEditor({
}
>
<div className={cn(isDistractionFree ? "w-full" : "flex-1 min-w-0 overflow-y-auto p-6")}>
{/* In distraction-free mode the header is a hover-revealed overlay. */}
{/* In distraction-free mode the header is an always-visible overlay
so readers can discover the exit affordance without hovering. */}
<div
className={cn(
"flex flex-wrap items-center justify-between gap-y-2",
isDistractionFree
? "opacity-0 hover:opacity-100 transition-opacity duration-200 fixed top-0 start-0 end-0 mx-auto w-[calc(100%-4rem)] max-w-3xl bg-kumo-elevated/95 py-4 backdrop-blur z-10"
? "fixed top-0 start-0 end-0 mx-auto w-[calc(100%-4rem)] max-w-3xl bg-kumo-elevated/95 py-4 backdrop-blur z-10"
: cn(
"mx-auto mb-6 max-w-3xl",
isBelowLg && "bg-kumo-elevated/95 py-3 backdrop-blur",
Expand Down Expand Up @@ -1094,6 +1094,7 @@ export function ContentEditor({
type="button"
onClick={() => setIsDistractionFree(false)}
aria-label={t`Exit distraction-free mode`}
title={t`Exit distraction-free mode (⌘⇧\\)`}
>
<ArrowsInSimple className="h-5 w-5" aria-hidden="true" />
</Button>
Expand Down
52 changes: 43 additions & 9 deletions packages/admin/tests/components/ContentEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ describe("ContentEditor", () => {
expect(backdrop?.classList.contains("pointer-events-none") ?? true).toBe(true);

// Exiting DF with the panel still active surfaces it in the sheet.
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await screen.getByRole("button", { name: "Exit distraction-free mode" }).click();
await expect
.element(screen.getByRole("navigation", { name: "Settings" }))
.toBeInTheDocument();
Expand Down Expand Up @@ -1761,6 +1761,40 @@ describe("ContentEditor", () => {
});

describe("distraction-free mode", () => {
function dispatchDistractionFreeShortcut() {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "\\",
shiftKey: true,
// `mod` maps to Ctrl on Linux/Windows and Cmd on macOS; firing both
// modifiers keeps the test deterministic across Playwright hosts.
ctrlKey: true,
metaKey: true,
bubbles: true,
}),
);
}

function getMainForm() {
return document.querySelector("form");
}

function isDistractionFree() {
return getMainForm()?.classList.toString().includes("fixed") ?? false;
}

it("toggles in and out with the advertised keyboard shortcut", async () => {
await renderEditor({ isNew: true });

expect(isDistractionFree()).toBe(false);

dispatchDistractionFreeShortcut();
await vi.waitFor(() => expect(isDistractionFree()).toBe(true));

dispatchDistractionFreeShortcut();
await vi.waitFor(() => expect(isDistractionFree()).toBe(false));
});

it("keeps the normal editor width and field chrome", async () => {
const screen = await renderEditor({
fields: {
Expand Down Expand Up @@ -1861,23 +1895,23 @@ describe("ContentEditor", () => {
expect(form?.classList.toString()).toContain("fixed");
});

it("escape exits distraction-free mode", async () => {
it("does not exit distraction-free mode with Escape", async () => {
const screen = await renderEditor({ isNew: true });
const enterBtn = screen.getByRole("button", { name: "Enter distraction-free mode" });
await enterBtn.click();

// Verify we're in distraction-free mode
let form = document.querySelector("form");
expect(form?.classList.toString()).toContain("fixed");
expect(document.querySelector("form")?.classList.toString()).toContain("fixed");

// Press Escape
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));

// Wait for the state to update
await vi.waitFor(() => {
form = document.querySelector("form");
expect(form?.classList.toString()).not.toContain("fixed");
});
// Wait long enough that any errant state update would have been applied.
await new Promise((resolve) => setTimeout(resolve, 100));

// Escape is reserved for other actions on the Posts page and must not
// leave distraction-free mode.
expect(document.querySelector("form")?.classList.toString()).toContain("fixed");
});

it("keeps Live View available in distraction-free mode", async () => {
Expand Down
Loading