mirror of
https://github.com/usememos/memos.git
synced 2025-12-13 00:27:44 +08:00
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.
This commit is contained in:
parent
156908c77f
commit
c8162ff3cc
3 changed files with 112 additions and 6 deletions
|
|
@ -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…
Add table
Reference in a new issue