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.
pull/5256/head
Johnny 2 weeks ago
parent 156908c77f
commit c8162ff3cc

@ -6,6 +6,19 @@ import { editorCommands } from "./commands";
import TagSuggestions from "./TagSuggestions";
import { useListAutoCompletion } from "./useListAutoCompletion";
/**
* Editor height constraints
* - Normal mode: Limited to 50% viewport height to avoid excessive scrolling
* - Focus mode: Minimum 50vh on mobile, 60vh on desktop for immersive writing
*/
const EDITOR_HEIGHT = {
normal: "max-h-[50vh]",
focusMode: {
mobile: "min-h-[50vh]",
desktop: "md:min-h-[60vh]",
},
} as const;
export interface EditorRefActions {
getEditor: () => HTMLTextAreaElement | null;
focus: FunctionType;
@ -30,10 +43,12 @@ interface Props {
commands?: Command[];
onContentChange: (content: string) => void;
onPaste: (event: React.ClipboardEvent) => void;
/** Whether Focus Mode is active - adjusts height constraints for immersive writing */
isFocusMode?: boolean;
}
const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<EditorRefActions>) {
const { className, initialContent, placeholder, onPaste, onContentChange: handleContentChangeCallback } = props;
const { className, initialContent, placeholder, onPaste, onContentChange: handleContentChangeCallback, isFocusMode } = props;
const [isInIME, setIsInIME] = useState(false);
const editorRef = useRef<HTMLTextAreaElement>(null);
@ -160,9 +175,18 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
});
return (
<div className={cn("flex flex-col justify-start items-start relative w-full h-auto max-h-[50vh] bg-inherit", className)}>
<div
className={cn(
"flex flex-col justify-start items-start relative w-full h-auto bg-inherit",
isFocusMode ? "flex-1" : EDITOR_HEIGHT.normal,
className,
)}
>
<textarea
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"
className={cn(
"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",
isFocusMode ? `h-auto ${EDITOR_HEIGHT.focusMode.mobile} ${EDITOR_HEIGHT.focusMode.desktop}` : "h-full",
)}
rows={1}
placeholder={placeholder}
ref={editorRef}

@ -1,6 +1,6 @@
import copy from "copy-to-clipboard";
import { isEqual } from "lodash-es";
import { LoaderIcon } from "lucide-react";
import { LoaderIcon, Minimize2Icon } from "lucide-react";
import { observer } from "mobx-react-lite";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "react-hot-toast";
@ -27,6 +27,34 @@ import Editor, { EditorRefActions } from "./Editor";
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers";
import { MemoEditorContext } from "./types";
/**
* Focus Mode keyboard shortcuts
* - Toggle: Cmd/Ctrl + Shift + F (matches GitHub, Google Docs convention)
* - Exit: Escape key
*/
const FOCUS_MODE_TOGGLE_KEY = "f";
const FOCUS_MODE_EXIT_KEY = "Escape";
/**
* Focus Mode styling constants
* Centralized to make it easy to adjust the appearance and maintain consistency
*/
const FOCUS_MODE_STYLES = {
backdrop: "fixed inset-0 bg-black/20 backdrop-blur-sm z-40",
container: {
base: "fixed z-50 w-auto max-w-5xl mx-auto shadow-2xl border-border h-auto overflow-y-auto",
/**
* Responsive spacing using explicit positioning to avoid width conflicts:
* - Mobile (< 640px): 8px margin (0.5rem)
* - Tablet (640-768px): 16px margin (1rem)
* - Desktop (> 768px): 32px margin (2rem)
*/
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",
},
transition: "transition-all duration-300 ease-in-out",
exitButton: "absolute top-2 right-2 z-10 opacity-60 hover:opacity-100",
} as const;
export interface Props {
className?: string;
cacheKey?: string;
@ -49,6 +77,8 @@ interface State {
isRequesting: boolean;
isComposing: boolean;
isDraggingFile: boolean;
/** Whether Focus Mode (distraction-free writing) is enabled */
isFocusMode: boolean;
}
const MemoEditor = observer((props: Props) => {
@ -58,6 +88,7 @@ const MemoEditor = observer((props: Props) => {
const currentUser = useCurrentUser();
const [state, setState] = useState<State>({
memoVisibility: Visibility.PRIVATE,
isFocusMode: false,
attachmentList: [],
relationList: [],
location: undefined,
@ -149,6 +180,21 @@ const MemoEditor = observer((props: Props) => {
}
const isMetaKey = event.ctrlKey || event.metaKey;
// Focus Mode toggle: Cmd/Ctrl + Shift + F
if (isMetaKey && event.shiftKey && event.key.toLowerCase() === FOCUS_MODE_TOGGLE_KEY) {
event.preventDefault();
toggleFocusMode();
return;
}
// Exit Focus Mode: Escape
if (event.key === FOCUS_MODE_EXIT_KEY && state.isFocusMode) {
event.preventDefault();
toggleFocusMode();
return;
}
if (isMetaKey) {
if (event.key === "Enter") {
handleSaveBtnClick();
@ -171,6 +217,21 @@ const MemoEditor = observer((props: Props) => {
}
};
/**
* Toggle Focus Mode on/off
* Focus Mode provides a distraction-free writing experience with:
* - Expanded editor taking ~80-90% of viewport
* - Semi-transparent backdrop
* - Centered layout with optimal width
* - All editor functionality preserved
*/
const toggleFocusMode = () => {
setState((prevState) => ({
...prevState,
isFocusMode: !prevState.isFocusMode,
}));
};
const handleMemoVisibilityChange = (visibility: Visibility) => {
setState((prevState) => ({
...prevState,
@ -446,8 +507,9 @@ const MemoEditor = observer((props: Props) => {
placeholder: props.placeholder ?? t("editor.any-thoughts"),
onContentChange: handleContentChange,
onPaste: handlePasteEvent,
isFocusMode: state.isFocusMode,
}),
[i18n.language],
[i18n.language, state.isFocusMode],
);
const allowSave = (hasContent || state.attachmentList.length > 0) && !state.isUploadingAttachment && !state.isRequesting;
@ -472,10 +534,15 @@ const MemoEditor = observer((props: Props) => {
memoName,
}}
>
{/* Focus Mode Backdrop */}
{state.isFocusMode && <div className={FOCUS_MODE_STYLES.backdrop} onClick={toggleFocusMode} />}
<div
className={cn(
"group relative w-full flex flex-col justify-start items-start bg-card px-4 pt-3 pb-2 rounded-lg border",
FOCUS_MODE_STYLES.transition,
state.isDraggingFile ? "border-dashed border-muted-foreground cursor-copy" : "border-border cursor-auto",
state.isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
className,
)}
tabIndex={0}
@ -487,6 +554,19 @@ const MemoEditor = observer((props: Props) => {
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
>
{/* Focus Mode Exit Button */}
{state.isFocusMode && (
<Button
variant="ghost"
size="icon"
className={FOCUS_MODE_STYLES.exitButton}
onClick={toggleFocusMode}
title={t("editor.exit-focus-mode")}
>
<Minimize2Icon className="w-4 h-4" />
</Button>
)}
<Editor ref={editorRef} {...editorConfig} />
<LocationDisplay
mode="edit"

@ -117,7 +117,9 @@
"add-your-comment-here": "Add your comment here...",
"any-thoughts": "Any thoughts...",
"save": "Save",
"no-changes-detected": "No changes detected"
"no-changes-detected": "No changes detected",
"focus-mode": "Focus Mode",
"exit-focus-mode": "Exit Focus Mode"
},
"filters": {
"has-code": "hasCode",

Loading…
Cancel
Save