memos/web/src/components/MemoEditor/index.tsx
Johnny c8162ff3cc 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.
2025-11-16 23:15:36 +08:00

645 lines
21 KiB
TypeScript

import copy from "copy-to-clipboard";
import { isEqual } from "lodash-es";
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";
import { useTranslation } from "react-i18next";
import useLocalStorage from "react-use/lib/useLocalStorage";
import { Button } from "@/components/ui/button";
import { memoServiceClient } from "@/grpcweb";
import { TAB_SPACE_WIDTH } from "@/helpers/consts";
import { isValidUrl } from "@/helpers/utils";
import useAsyncEffect from "@/hooks/useAsyncEffect";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import { attachmentStore, instanceStore, memoStore, userStore } from "@/store";
import { extractMemoIdFromName } from "@/store/common";
import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { Location, Memo, MemoRelation, MemoRelation_Type, Visibility } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString } from "@/utils/memo";
import DateTimeInput from "../DateTimeInput";
import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata";
import InsertMenu from "./ActionButton/InsertMenu";
import VisibilitySelector from "./ActionButton/VisibilitySelector";
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;
placeholder?: string;
// The name of the memo to be edited.
memoName?: string;
// The name of the parent memo if the memo is a comment.
parentMemoName?: string;
autoFocus?: boolean;
onConfirm?: (memoName: string) => void;
onCancel?: () => void;
}
interface State {
memoVisibility: Visibility;
attachmentList: Attachment[];
relationList: MemoRelation[];
location: Location | undefined;
isUploadingAttachment: boolean;
isRequesting: boolean;
isComposing: boolean;
isDraggingFile: boolean;
/** Whether Focus Mode (distraction-free writing) is enabled */
isFocusMode: boolean;
}
const MemoEditor = observer((props: Props) => {
const { className, cacheKey, memoName, parentMemoName, autoFocus, onConfirm, onCancel } = props;
const t = useTranslate();
const { i18n } = useTranslation();
const currentUser = useCurrentUser();
const [state, setState] = useState<State>({
memoVisibility: Visibility.PRIVATE,
isFocusMode: false,
attachmentList: [],
relationList: [],
location: undefined,
isUploadingAttachment: false,
isRequesting: false,
isComposing: false,
isDraggingFile: false,
});
const [createTime, setCreateTime] = useState<Date | undefined>();
const [updateTime, setUpdateTime] = useState<Date | undefined>();
const [hasContent, setHasContent] = useState<boolean>(false);
const editorRef = useRef<EditorRefActions>(null);
const userGeneralSetting = userStore.state.userGeneralSetting;
const contentCacheKey = `${currentUser.name}-${cacheKey || ""}`;
const [contentCache, setContentCache] = useLocalStorage<string>(contentCacheKey, "");
const referenceRelations = memoName
? state.relationList.filter(
(relation) =>
relation.memo?.name === memoName && relation.relatedMemo?.name !== memoName && relation.type === MemoRelation_Type.REFERENCE,
)
: state.relationList.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
useEffect(() => {
editorRef.current?.setContent(contentCache || "");
}, []);
useEffect(() => {
if (autoFocus) {
handleEditorFocus();
}
}, [autoFocus]);
useAsyncEffect(async () => {
let visibility = convertVisibilityFromString(userGeneralSetting?.memoVisibility || "PRIVATE");
if (instanceMemoRelatedSetting.disallowPublicVisibility && visibility === Visibility.PUBLIC) {
visibility = Visibility.PROTECTED;
}
if (parentMemoName) {
const parentMemo = await memoStore.getOrFetchMemoByName(parentMemoName);
visibility = parentMemo.visibility;
}
setState((prevState) => ({
...prevState,
memoVisibility: convertVisibilityFromString(visibility),
}));
}, [parentMemoName, userGeneralSetting?.memoVisibility, instanceMemoRelatedSetting.disallowPublicVisibility]);
useAsyncEffect(async () => {
if (!memoName) {
return;
}
const memo = await memoStore.getOrFetchMemoByName(memoName);
if (memo) {
handleEditorFocus();
setCreateTime(memo.createTime);
setUpdateTime(memo.updateTime);
setState((prevState) => ({
...prevState,
memoVisibility: memo.visibility,
attachmentList: memo.attachments,
relationList: memo.relations,
location: memo.location,
}));
if (!contentCache) {
editorRef.current?.setContent(memo.content ?? "");
}
}
}, [memoName]);
const handleCompositionStart = () => {
setState((prevState) => ({
...prevState,
isComposing: true,
}));
};
const handleCompositionEnd = () => {
setState((prevState) => ({
...prevState,
isComposing: false,
}));
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (!editorRef.current) {
return;
}
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();
return;
}
if (!instanceMemoRelatedSetting.disableMarkdownShortcuts) {
handleEditorKeydownWithMarkdownShortcuts(event, editorRef.current);
}
}
if (event.key === "Tab" && !state.isComposing) {
event.preventDefault();
const tabSpace = " ".repeat(TAB_SPACE_WIDTH);
const cursorPosition = editorRef.current.getCursorPosition();
const selectedContent = editorRef.current.getSelectedContent();
editorRef.current.insertText(tabSpace);
if (selectedContent) {
editorRef.current.setCursorPosition(cursorPosition + TAB_SPACE_WIDTH);
}
return;
}
};
/**
* 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,
memoVisibility: visibility,
}));
};
const handleSetAttachmentList = (attachmentList: Attachment[]) => {
setState((prevState) => ({
...prevState,
attachmentList,
}));
};
const handleSetRelationList = (relationList: MemoRelation[]) => {
setState((prevState) => ({
...prevState,
relationList,
}));
};
const handleUploadResource = async (file: File) => {
setState((state) => {
return {
...state,
isUploadingAttachment: true,
};
});
const { name: filename, size, type } = file;
const buffer = new Uint8Array(await file.arrayBuffer());
try {
const attachment = await attachmentStore.createAttachment({
attachment: Attachment.fromPartial({
filename,
size,
type,
content: buffer,
}),
attachmentId: "",
});
setState((state) => {
return {
...state,
isUploadingAttachment: false,
};
});
return attachment;
} catch (error: any) {
console.error(error);
toast.error(error.details);
setState((state) => {
return {
...state,
isUploadingAttachment: false,
};
});
}
};
const uploadMultiFiles = async (files: FileList) => {
const uploadedAttachmentList: Attachment[] = [];
for (const file of files) {
const attachment = await handleUploadResource(file);
if (attachment) {
uploadedAttachmentList.push(attachment);
if (memoName) {
await attachmentStore.updateAttachment({
attachment: Attachment.fromPartial({
name: attachment.name,
memo: memoName,
}),
updateMask: ["memo"],
});
}
}
}
if (uploadedAttachmentList.length > 0) {
setState((prevState) => ({
...prevState,
attachmentList: [...prevState.attachmentList, ...uploadedAttachmentList],
}));
}
};
const handleDropEvent = async (event: React.DragEvent) => {
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
event.preventDefault();
setState((prevState) => ({
...prevState,
isDraggingFile: false,
}));
await uploadMultiFiles(event.dataTransfer.files);
}
};
const handleDragOver = (event: React.DragEvent) => {
if (event.dataTransfer && event.dataTransfer.types.includes("Files")) {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
if (!state.isDraggingFile) {
setState((prevState) => ({
...prevState,
isDraggingFile: true,
}));
}
}
};
const handleDragLeave = (event: React.DragEvent) => {
event.preventDefault();
setState((prevState) => ({
...prevState,
isDraggingFile: false,
}));
};
const handlePasteEvent = async (event: React.ClipboardEvent) => {
if (event.clipboardData && event.clipboardData.files.length > 0) {
event.preventDefault();
await uploadMultiFiles(event.clipboardData.files);
} else if (
editorRef.current != null &&
editorRef.current.getSelectedContent().length != 0 &&
isValidUrl(event.clipboardData.getData("Text"))
) {
event.preventDefault();
hyperlinkHighlightedText(editorRef.current, event.clipboardData.getData("Text"));
}
};
const handleContentChange = (content: string) => {
setHasContent(content !== "");
if (content !== "") {
setContentCache(content);
} else {
localStorage.removeItem(contentCacheKey);
}
};
const handleSaveBtnClick = async () => {
if (state.isRequesting) {
return;
}
setState((state) => {
return {
...state,
isRequesting: true,
};
});
const content = editorRef.current?.getContent() ?? "";
try {
// Update memo.
if (memoName) {
const prevMemo = await memoStore.getOrFetchMemoByName(memoName);
if (prevMemo) {
const updateMask = new Set<string>();
const memoPatch: Partial<Memo> = {
name: prevMemo.name,
content,
};
if (!isEqual(content, prevMemo.content)) {
updateMask.add("content");
memoPatch.content = content;
}
if (!isEqual(state.memoVisibility, prevMemo.visibility)) {
updateMask.add("visibility");
memoPatch.visibility = state.memoVisibility;
}
if (!isEqual(state.attachmentList, prevMemo.attachments)) {
updateMask.add("attachments");
memoPatch.attachments = state.attachmentList;
}
if (!isEqual(state.relationList, prevMemo.relations)) {
updateMask.add("relations");
memoPatch.relations = state.relationList;
}
if (!isEqual(state.location, prevMemo.location)) {
updateMask.add("location");
memoPatch.location = state.location;
}
if (["content", "attachments", "relations", "location"].some((key) => updateMask.has(key))) {
updateMask.add("update_time");
}
if (createTime && !isEqual(createTime, prevMemo.createTime)) {
updateMask.add("create_time");
memoPatch.createTime = createTime;
}
if (updateTime && !isEqual(updateTime, prevMemo.updateTime)) {
updateMask.add("update_time");
memoPatch.updateTime = updateTime;
}
if (updateMask.size === 0) {
toast.error(t("editor.no-changes-detected"));
if (onCancel) {
onCancel();
}
return;
}
const memo = await memoStore.updateMemo(memoPatch, Array.from(updateMask));
if (onConfirm) {
onConfirm(memo.name);
}
}
} else {
// Create memo or memo comment.
const request = !parentMemoName
? memoStore.createMemo({
memo: Memo.fromPartial({
content,
visibility: state.memoVisibility,
attachments: state.attachmentList,
relations: state.relationList,
location: state.location,
}),
memoId: "",
})
: memoServiceClient
.createMemoComment({
name: parentMemoName,
comment: {
content,
visibility: state.memoVisibility,
attachments: state.attachmentList,
relations: state.relationList,
location: state.location,
},
})
.then((memo) => memo);
const memo = await request;
if (onConfirm) {
onConfirm(memo.name);
}
}
editorRef.current?.setContent("");
} catch (error: any) {
console.error(error);
toast.error(error.details);
}
localStorage.removeItem(contentCacheKey);
setState((state) => {
return {
...state,
isRequesting: false,
attachmentList: [],
relationList: [],
location: undefined,
isDraggingFile: false,
};
});
};
const handleCancelBtnClick = () => {
localStorage.removeItem(contentCacheKey);
if (onCancel) {
onCancel();
}
};
const handleEditorFocus = () => {
editorRef.current?.focus();
};
const editorConfig = useMemo(
() => ({
className: "",
initialContent: "",
placeholder: props.placeholder ?? t("editor.any-thoughts"),
onContentChange: handleContentChange,
onPaste: handlePasteEvent,
isFocusMode: state.isFocusMode,
}),
[i18n.language, state.isFocusMode],
);
const allowSave = (hasContent || state.attachmentList.length > 0) && !state.isUploadingAttachment && !state.isRequesting;
return (
<MemoEditorContext.Provider
value={{
attachmentList: state.attachmentList,
relationList: state.relationList,
setAttachmentList: (attachmentList: Attachment[]) => {
setState((prevState) => ({
...prevState,
attachmentList,
}));
},
setRelationList: (relationList: MemoRelation[]) => {
setState((prevState) => ({
...prevState,
relationList,
}));
},
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}
onKeyDown={handleKeyDown}
onDrop={handleDropEvent}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onFocus={handleEditorFocus}
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"
location={state.location}
onRemove={() =>
setState((prevState) => ({
...prevState,
location: undefined,
}))
}
/>
<AttachmentList mode="edit" attachments={state.attachmentList} onAttachmentsChange={handleSetAttachmentList} />
<RelationList mode="edit" relations={referenceRelations} onRelationsChange={handleSetRelationList} />
<div className="relative w-full flex flex-row justify-between items-center pt-2 gap-2" onFocus={(e) => e.stopPropagation()}>
<div className="flex flex-row justify-start items-center gap-1">
<InsertMenu
isUploading={state.isUploadingAttachment}
location={state.location}
onLocationChange={(location) =>
setState((prevState) => ({
...prevState,
location,
}))
}
/>
</div>
<div className="shrink-0 flex flex-row justify-end items-center">
<VisibilitySelector value={state.memoVisibility} onChange={(visibility) => handleMemoVisibilityChange(visibility)} />
<div className="flex flex-row justify-end gap-1">
{props.onCancel && (
<Button variant="ghost" disabled={state.isRequesting} onClick={handleCancelBtnClick}>
{t("common.cancel")}
</Button>
)}
<Button disabled={!allowSave || state.isRequesting} onClick={handleSaveBtnClick}>
{state.isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
</Button>
</div>
</div>
</div>
</div>
{/* Show memo metadata if memoName is provided */}
{memoName && (
<div className="w-full -mt-1 mb-4 text-xs leading-5 px-4 opacity-60 font-mono text-muted-foreground">
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 items-center">
{!isEqual(createTime, updateTime) && updateTime && (
<>
<span className="text-left">Updated</span>
<DateTimeInput value={updateTime} onChange={setUpdateTime} />
</>
)}
{createTime && (
<>
<span className="text-left">Created</span>
<DateTimeInput value={createTime} onChange={setCreateTime} />
</>
)}
<span className="text-left">ID</span>
<span
className="px-1 border border-transparent cursor-default"
onClick={() => {
copy(extractMemoIdFromName(memoName));
toast.success(t("message.copied"));
}}
>
{extractMemoIdFromName(memoName)}
</span>
</div>
</div>
)}
</MemoEditorContext.Provider>
);
});
export default MemoEditor;