Skip to content

Commit c8162ff

Browse files
committed
feat(web): add Focus Mode for distraction-free writing
Add keyboard-activated Focus Mode to provide an immersive writing experience: Features: - Toggle with Cmd/Ctrl+Shift+F (matches GitHub, Google Docs) - Exit with Escape, toggle shortcut, button click, or backdrop click - Expands editor to ~80-90% of viewport with centered layout - Semi-transparent backdrop with blur effect - Maintains all editor functionality (attachments, shortcuts, etc.) - Smooth 300ms transitions Responsive Design: - Mobile (< 640px): 8px margins, 50vh min-height - Tablet (640-768px): 16px margins - Desktop (> 768px): 32px margins, 60vh min-height, 1024px max-width Implementation: - Centralized constants for easy maintenance (FOCUS_MODE_STYLES) - Extracted keyboard shortcuts and heights to named constants - JSDoc documentation for all new functions and interfaces - TypeScript type safety with 'as const' - Explicit positioning (top/left/right/bottom) to avoid width overflow Files Modified: - web/src/components/MemoEditor/index.tsx - Main Focus Mode logic - web/src/components/MemoEditor/Editor/index.tsx - Height adjustments - web/src/locales/en.json - Translation keys Design follows industry standards (GitHub Focus Mode, Notion, Obsidian) and maintains code quality with single source of truth pattern.
1 parent 156908c commit c8162ff

File tree

3 files changed

+112
-6
lines changed

3 files changed

+112
-6
lines changed

web/src/components/MemoEditor/Editor/index.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ import { editorCommands } from "./commands";
66
import TagSuggestions from "./TagSuggestions";
77
import { useListAutoCompletion } from "./useListAutoCompletion";
88

9+
/**
10+
* Editor height constraints
11+
* - Normal mode: Limited to 50% viewport height to avoid excessive scrolling
12+
* - Focus mode: Minimum 50vh on mobile, 60vh on desktop for immersive writing
13+
*/
14+
const EDITOR_HEIGHT = {
15+
normal: "max-h-[50vh]",
16+
focusMode: {
17+
mobile: "min-h-[50vh]",
18+
desktop: "md:min-h-[60vh]",
19+
},
20+
} as const;
21+
922
export interface EditorRefActions {
1023
getEditor: () => HTMLTextAreaElement | null;
1124
focus: FunctionType;
@@ -30,10 +43,12 @@ interface Props {
3043
commands?: Command[];
3144
onContentChange: (content: string) => void;
3245
onPaste: (event: React.ClipboardEvent) => void;
46+
/** Whether Focus Mode is active - adjusts height constraints for immersive writing */
47+
isFocusMode?: boolean;
3348
}
3449

3550
const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<EditorRefActions>) {
36-
const { className, initialContent, placeholder, onPaste, onContentChange: handleContentChangeCallback } = props;
51+
const { className, initialContent, placeholder, onPaste, onContentChange: handleContentChangeCallback, isFocusMode } = props;
3752
const [isInIME, setIsInIME] = useState(false);
3853
const editorRef = useRef<HTMLTextAreaElement>(null);
3954

@@ -160,9 +175,18 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
160175
});
161176

162177
return (
163-
<div className={cn("flex flex-col justify-start items-start relative w-full h-auto max-h-[50vh] bg-inherit", className)}>
178+
<div
179+
className={cn(
180+
"flex flex-col justify-start items-start relative w-full h-auto bg-inherit",
181+
isFocusMode ? "flex-1" : EDITOR_HEIGHT.normal,
182+
className,
183+
)}
184+
>
164185
<textarea
165-
className="w-full h-full my-1 text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none placeholder:opacity-70 whitespace-pre-wrap break-words"
186+
className={cn(
187+
"w-full my-1 text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none placeholder:opacity-70 whitespace-pre-wrap break-words",
188+
isFocusMode ? `h-auto ${EDITOR_HEIGHT.focusMode.mobile} ${EDITOR_HEIGHT.focusMode.desktop}` : "h-full",
189+
)}
166190
rows={1}
167191
placeholder={placeholder}
168192
ref={editorRef}

web/src/components/MemoEditor/index.tsx

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import copy from "copy-to-clipboard";
22
import { isEqual } from "lodash-es";
3-
import { LoaderIcon } from "lucide-react";
3+
import { LoaderIcon, Minimize2Icon } from "lucide-react";
44
import { observer } from "mobx-react-lite";
55
import React, { useEffect, useMemo, useRef, useState } from "react";
66
import { toast } from "react-hot-toast";
@@ -27,6 +27,34 @@ import Editor, { EditorRefActions } from "./Editor";
2727
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers";
2828
import { MemoEditorContext } from "./types";
2929

30+
/**
31+
* Focus Mode keyboard shortcuts
32+
* - Toggle: Cmd/Ctrl + Shift + F (matches GitHub, Google Docs convention)
33+
* - Exit: Escape key
34+
*/
35+
const FOCUS_MODE_TOGGLE_KEY = "f";
36+
const FOCUS_MODE_EXIT_KEY = "Escape";
37+
38+
/**
39+
* Focus Mode styling constants
40+
* Centralized to make it easy to adjust the appearance and maintain consistency
41+
*/
42+
const FOCUS_MODE_STYLES = {
43+
backdrop: "fixed inset-0 bg-black/20 backdrop-blur-sm z-40",
44+
container: {
45+
base: "fixed z-50 w-auto max-w-5xl mx-auto shadow-2xl border-border h-auto overflow-y-auto",
46+
/**
47+
* Responsive spacing using explicit positioning to avoid width conflicts:
48+
* - Mobile (< 640px): 8px margin (0.5rem)
49+
* - Tablet (640-768px): 16px margin (1rem)
50+
* - Desktop (> 768px): 32px margin (2rem)
51+
*/
52+
spacing: "top-2 left-2 right-2 bottom-2 sm:top-4 sm:left-4 sm:right-4 sm:bottom-4 md:top-8 md:left-8 md:right-8 md:bottom-8",
53+
},
54+
transition: "transition-all duration-300 ease-in-out",
55+
exitButton: "absolute top-2 right-2 z-10 opacity-60 hover:opacity-100",
56+
} as const;
57+
3058
export interface Props {
3159
className?: string;
3260
cacheKey?: string;
@@ -49,6 +77,8 @@ interface State {
4977
isRequesting: boolean;
5078
isComposing: boolean;
5179
isDraggingFile: boolean;
80+
/** Whether Focus Mode (distraction-free writing) is enabled */
81+
isFocusMode: boolean;
5282
}
5383

5484
const MemoEditor = observer((props: Props) => {
@@ -58,6 +88,7 @@ const MemoEditor = observer((props: Props) => {
5888
const currentUser = useCurrentUser();
5989
const [state, setState] = useState<State>({
6090
memoVisibility: Visibility.PRIVATE,
91+
isFocusMode: false,
6192
attachmentList: [],
6293
relationList: [],
6394
location: undefined,
@@ -149,6 +180,21 @@ const MemoEditor = observer((props: Props) => {
149180
}
150181

151182
const isMetaKey = event.ctrlKey || event.metaKey;
183+
184+
// Focus Mode toggle: Cmd/Ctrl + Shift + F
185+
if (isMetaKey && event.shiftKey && event.key.toLowerCase() === FOCUS_MODE_TOGGLE_KEY) {
186+
event.preventDefault();
187+
toggleFocusMode();
188+
return;
189+
}
190+
191+
// Exit Focus Mode: Escape
192+
if (event.key === FOCUS_MODE_EXIT_KEY && state.isFocusMode) {
193+
event.preventDefault();
194+
toggleFocusMode();
195+
return;
196+
}
197+
152198
if (isMetaKey) {
153199
if (event.key === "Enter") {
154200
handleSaveBtnClick();
@@ -171,6 +217,21 @@ const MemoEditor = observer((props: Props) => {
171217
}
172218
};
173219

220+
/**
221+
* Toggle Focus Mode on/off
222+
* Focus Mode provides a distraction-free writing experience with:
223+
* - Expanded editor taking ~80-90% of viewport
224+
* - Semi-transparent backdrop
225+
* - Centered layout with optimal width
226+
* - All editor functionality preserved
227+
*/
228+
const toggleFocusMode = () => {
229+
setState((prevState) => ({
230+
...prevState,
231+
isFocusMode: !prevState.isFocusMode,
232+
}));
233+
};
234+
174235
const handleMemoVisibilityChange = (visibility: Visibility) => {
175236
setState((prevState) => ({
176237
...prevState,
@@ -446,8 +507,9 @@ const MemoEditor = observer((props: Props) => {
446507
placeholder: props.placeholder ?? t("editor.any-thoughts"),
447508
onContentChange: handleContentChange,
448509
onPaste: handlePasteEvent,
510+
isFocusMode: state.isFocusMode,
449511
}),
450-
[i18n.language],
512+
[i18n.language, state.isFocusMode],
451513
);
452514

453515
const allowSave = (hasContent || state.attachmentList.length > 0) && !state.isUploadingAttachment && !state.isRequesting;
@@ -472,10 +534,15 @@ const MemoEditor = observer((props: Props) => {
472534
memoName,
473535
}}
474536
>
537+
{/* Focus Mode Backdrop */}
538+
{state.isFocusMode && <div className={FOCUS_MODE_STYLES.backdrop} onClick={toggleFocusMode} />}
539+
475540
<div
476541
className={cn(
477542
"group relative w-full flex flex-col justify-start items-start bg-card px-4 pt-3 pb-2 rounded-lg border",
543+
FOCUS_MODE_STYLES.transition,
478544
state.isDraggingFile ? "border-dashed border-muted-foreground cursor-copy" : "border-border cursor-auto",
545+
state.isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
479546
className,
480547
)}
481548
tabIndex={0}
@@ -487,6 +554,19 @@ const MemoEditor = observer((props: Props) => {
487554
onCompositionStart={handleCompositionStart}
488555
onCompositionEnd={handleCompositionEnd}
489556
>
557+
{/* Focus Mode Exit Button */}
558+
{state.isFocusMode && (
559+
<Button
560+
variant="ghost"
561+
size="icon"
562+
className={FOCUS_MODE_STYLES.exitButton}
563+
onClick={toggleFocusMode}
564+
title={t("editor.exit-focus-mode")}
565+
>
566+
<Minimize2Icon className="w-4 h-4" />
567+
</Button>
568+
)}
569+
490570
<Editor ref={editorRef} {...editorConfig} />
491571
<LocationDisplay
492572
mode="edit"

web/src/locales/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@
117117
"add-your-comment-here": "Add your comment here...",
118118
"any-thoughts": "Any thoughts...",
119119
"save": "Save",
120-
"no-changes-detected": "No changes detected"
120+
"no-changes-detected": "No changes detected",
121+
"focus-mode": "Focus Mode",
122+
"exit-focus-mode": "Exit Focus Mode"
121123
},
122124
"filters": {
123125
"has-code": "hasCode",

0 commit comments

Comments
 (0)