feat: add desktop focus mode (#57)

This commit is contained in:
tianma-if committed 2026-07-21 11:48:18 +08:00
1 parent ed8a8f091f
commit da4d9155d9
9 files changed
+150 -13

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ The public demo resets every Monday at 1:00 AM (China Standard Time) and restore
- EdgeEver ZIP import and export: one archive combines human-readable Markdown, Front Matter, nested notebook structure, and relative-path attachments with versioned structured data and revision history for complete recovery between EdgeEver instances.
- AI Agent friendly: built-in MCP support lets tools such as Codex, Claude Code, and Antigravity read, organize, and maintain notes, while enabling integrations with Notion databases and Feishu Bitable.
- Uncapped multi-device sync: self-hosted API means no restrictive commercial limits on the number of active login devices, supporting seamless synchronization across PC, tablet, and mobile (via PWA or browser).
- Three-pane layout: notebook tree, note list, and main editor.
- Three-pane layout: notebook tree, note list, and main editor, with a desktop focus mode that expands the selected note across the workspace.
- Unlimited nested notebooks.
- Rich text editing.
- Switch between Markdown source and rich text views on desktop.
+1 -1
View File
@@ -34,7 +34,7 @@ EdgeEver 是一个开源、自托管、Cloudflare-native 的现代笔记工作
- EdgeEver ZIP 导入与导出:同一份档案既包含便于直接阅读和迁移的 Markdown、Front Matter、嵌套笔记本结构与相对路径附件,也包含用于 EdgeEver 实例间完整恢复的版本化结构数据和历史版本。
- AI Agent 友好:原生支持 MCP,可让 Codex、Claude Code、Antigravity 等工具读取、整理和维护笔记,并可借助 MCP 与 Notion Database、飞书多维表格进行联动。
- 多端无缝同步且不限设备数:基于自建的 API 个人独享数据,摆脱商业笔记平台对登录设备数量的强制限制(如免费版只允许登录 2 台设备等),支持 PC、平板与手机无缝多端同步。
- 三栏布局:笔记本树、笔记列表、主编辑区。
- 三栏布局:笔记本树、笔记列表、主编辑区;桌面端支持专注模式,可让当前笔记铺满工作区。
- 无限级嵌套笔记本。
- 支持富文本编辑。
- 桌面端支持 Markdown 源码与富文本视图切换。
+2 -2
View File
@@ -3,7 +3,7 @@
"name": "EdgeEver",
"slug": "edgeever",
"scheme": "edgeever",
"version": "1.1.0",
"version": "1.2.0",
"runtimeVersion": {
"policy": "appVersion"
},
@@ -28,7 +28,7 @@
},
"android": {
"package": "org.edgeever.mobile",
"versionCode": 26,
"versionCode": 27,
"adaptiveIcon": {
"backgroundColor": "#d1fae5",
"backgroundImage": "./assets/icon.png",
+18 -1
View File
@@ -17,6 +17,8 @@ import {
Save,
ReplaceAll,
MoreHorizontal,
Maximize2,
Minimize2,
Paperclip,
Pencil,
Sparkles,
@@ -451,6 +453,8 @@ const MobileNotebookSelectSheet = ({
type EditorPaneProps = {
memo: MemoDetail | null;
desktopFocusMode: boolean;
onToggleDesktopFocusMode: () => void;
mobileDefaultEditMemoId: string | null;
preserveUnsavedContentFromMemoId?: string | null;
saveBlocked?: boolean;
@@ -1064,6 +1068,8 @@ export const EditorPane = (props: EditorPaneProps) => {
const RichEditorPane = ({
memo,
desktopFocusMode,
onToggleDesktopFocusMode,
mobileDefaultEditMemoId,
preserveUnsavedContentFromMemoId: _preserveUnsavedContentFromMemoId,
saveBlocked: _saveBlocked = false,
@@ -1086,6 +1092,7 @@ const RichEditorPane = ({
selectionActionBar,
onRequestMobileNativeEdit,
}: RichEditorPaneProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const isSelectionMode = Boolean(selectionActionBar);
const [title, setTitle] = useState("");
@@ -2306,6 +2313,16 @@ const RichEditorPane = ({
</button>
</div>
<div className="hidden items-center gap-1 lg:flex">
<Button
size="icon"
variant={desktopFocusMode ? "soft" : "ghost"}
title={t(desktopFocusMode ? "editor.exitFocusMode" : "editor.enterFocusMode")}
aria-label={t(desktopFocusMode ? "editor.exitFocusMode" : "editor.enterFocusMode")}
aria-pressed={desktopFocusMode}
onClick={onToggleDesktopFocusMode}
>
{desktopFocusMode ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</Button>
<Button size="icon" variant="ghost" title="上一条笔记" aria-label="上一条笔记" onClick={onOpenPreviousMemo} disabled={!hasPreviousMemo}>
<ChevronLeft className="h-4 w-4" />
</Button>
@@ -2481,7 +2498,7 @@ const RichEditorPane = ({
</div>
</div>
<div className="space-y-3 px-4 pb-4 pt-4 sm:px-7">
<div className="space-y-3 px-4 pb-4 pt-4 sm:px-7 lg:space-y-0 lg:pb-1 lg:pt-2">
<input
value={title}
readOnly={effectiveReadOnly}
+40 -8
View File
@@ -62,6 +62,8 @@ import {
isTextEntryTarget,
readImageCompressionPreference,
writeImageCompressionPreference,
readDesktopFocusModePreference,
writeDesktopFocusModePreference,
readShortcutSettingsPreference,
writeShortcutSettingsPreference,
getShortcutActionForEvent,
@@ -661,6 +663,7 @@ export const WorkspaceApp = ({
const [appNoticeDialog, setAppNoticeDialog] = useState<AppNoticeDialogState | null>(null);
const [multiSelectKeyDown, setMultiSelectKeyDown] = useState(false);
const [imageCompressionEnabled, setImageCompressionEnabled] = useState(readImageCompressionPreference);
const [desktopFocusMode, setDesktopFocusMode] = useState(readDesktopFocusModePreference);
const [shortcutSettings, setShortcutSettings] = useState<ShortcutSettings>(readShortcutSettingsPreference);
const [rightView, setRightView] = useState<"editor" | "settings" | "assets" | "tags" | "evernote-migration">(() =>
isInitialSettingsRoute ? "settings" : "editor"
@@ -1546,6 +1549,9 @@ export const WorkspaceApp = ({
? queryClient.getQueryData<{ memo: MemoDetail }>(memoDetailQueryKey(selectedMemoId, memoView))?.memo ?? null
: null;
const selectedMemo = memoQuery.data?.memo ?? cachedSelectedMemo;
const desktopFocusModeActive = Boolean(
isDesktop && desktopFocusMode && rightView === "editor" && selectedMemo && !memoSelectionModeActive
);
const selectionMoveNotebookOptions = useMemo(() => getNotebookMoveOptions(notebooks), [notebooks]);
const selectedMemosInCurrentList = useMemo(
() => memos.filter((memo) => selectedMemoIds.has(memo.id)),
@@ -1973,6 +1979,15 @@ export const WorkspaceApp = ({
}
};
const updateDesktopFocusMode = useCallback((enabled: boolean) => {
setDesktopFocusMode(enabled);
writeDesktopFocusModePreference(enabled);
}, []);
const toggleDesktopFocusMode = useCallback(() => {
updateDesktopFocusMode(!desktopFocusModeActive);
}, [desktopFocusModeActive, updateDesktopFocusMode]);
const handleWorkspaceBackRequest = useCallback(() => {
if (appNoticeDialog) {
setAppNoticeDialog(null);
@@ -2037,6 +2052,11 @@ export const WorkspaceApp = ({
return true;
}
if (desktopFocusModeActive) {
updateDesktopFocusMode(false);
return true;
}
if (rightView === "settings") {
handleCloseSettings();
return true;
@@ -2066,6 +2086,7 @@ export const WorkspaceApp = ({
return false;
}, [
visibleActivePane,
desktopFocusModeActive,
appNoticeDialog,
rightView,
clearMemoSelection,
@@ -2090,6 +2111,7 @@ export const WorkspaceApp = ({
notebookNameDialog,
templatesOpen,
updateNotebookMutation.isPending,
updateDesktopFocusMode,
]);
useBrowserBackLayer(workspaceBackTargetActive, handleWorkspaceBackRequest);
@@ -2324,16 +2346,22 @@ export const WorkspaceApp = ({
<main
className={cn(
"grid h-[100dvh] min-h-0 grid-cols-[minmax(0,1fr)]",
rightView === "editor"
? "lg:grid-cols-[260px_var(--memo-list-width)_minmax(0,1fr)]"
: "lg:grid-cols-[260px_1fr]"
desktopFocusModeActive
? "lg:grid-cols-[minmax(0,1fr)]"
: rightView === "editor"
? "lg:grid-cols-[260px_var(--memo-list-width)_minmax(0,1fr)]"
: "lg:grid-cols-[260px_1fr]"
)}
style={{ "--memo-list-width": `${memoListWidth}px` } as CSSProperties}
>
<aside
className={cn(
"min-h-0 border-r border-slate-200 bg-white/75 backdrop-blur-lg lg:block",
visibleActivePane === "notebooks" ? "block" : "hidden"
"min-h-0 border-r border-slate-200 bg-white/75 backdrop-blur-lg",
desktopFocusModeActive
? "hidden"
: visibleActivePane === "notebooks"
? "block lg:block"
: "hidden lg:block"
)}
>
{(isDesktop || visibleActivePane === "notebooks") && (
@@ -2389,9 +2417,11 @@ export const WorkspaceApp = ({
<section
className={cn(
"relative min-w-0 overflow-hidden border-r border-slate-200 bg-slate-50",
rightView === "editor"
? (visibleActivePane === "memos" ? "block lg:block lg:bg-white/75 lg:backdrop-blur-lg" : "hidden lg:block lg:bg-white/75 lg:backdrop-blur-lg")
: (visibleActivePane === "memos" ? "block lg:hidden" : "hidden lg:hidden")
desktopFocusModeActive
? "hidden"
: rightView === "editor"
? (visibleActivePane === "memos" ? "block lg:block lg:bg-white/75 lg:backdrop-blur-lg" : "hidden lg:block lg:bg-white/75 lg:backdrop-blur-lg")
: (visibleActivePane === "memos" ? "block lg:hidden" : "hidden lg:hidden")
)}
>
<MemoListPane
@@ -2534,6 +2564,8 @@ export const WorkspaceApp = ({
) : (
<EditorPane
memo={selectedMemo}
desktopFocusMode={desktopFocusModeActive}
onToggleDesktopFocusMode={toggleDesktopFocusMode}
mobileDefaultEditMemoId={createdMemoEditId}
isTrashView={memoView === "trash"}
notebooks={notebooks}
+67
View File
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
DESKTOP_FOCUS_MODE_STORAGE_KEY,
readDesktopFocusModePreference,
writeDesktopFocusModePreference,
} from "./app-helpers.ts";
const originalWindow = globalThis.window;
const installLocalStorage = (initialValue = null) => {
const values = new Map();
if (initialValue !== null) {
values.set(DESKTOP_FOCUS_MODE_STORAGE_KEY, initialValue);
}
globalThis.window = {
localStorage: {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
},
};
return values;
};
afterEach(() => {
globalThis.window = originalWindow;
});
describe("desktop focus mode preference", () => {
test("defaults to disabled and only accepts an explicit true value", () => {
installLocalStorage();
expect(readDesktopFocusModePreference()).toBe(false);
installLocalStorage("false");
expect(readDesktopFocusModePreference()).toBe(false);
installLocalStorage("true");
expect(readDesktopFocusModePreference()).toBe(true);
});
test("persists enabled and disabled values", () => {
const values = installLocalStorage();
writeDesktopFocusModePreference(true);
expect(values.get(DESKTOP_FOCUS_MODE_STORAGE_KEY)).toBe("true");
writeDesktopFocusModePreference(false);
expect(values.get(DESKTOP_FOCUS_MODE_STORAGE_KEY)).toBe("false");
});
test("fails closed when local storage is unavailable", () => {
globalThis.window = {
localStorage: {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("blocked");
},
},
};
expect(readDesktopFocusModePreference()).toBe(false);
expect(() => writeDesktopFocusModePreference(true)).not.toThrow();
});
});
+17
View File
@@ -145,6 +145,7 @@ export { buildNotebookTree, type NotebookNode };
export { DEFAULT_MEMO_TITLE };
export const IMAGE_COMPRESSION_STORAGE_KEY = "edgeever.imageCompressionEnabled";
export const DESKTOP_FOCUS_MODE_STORAGE_KEY = "edgeever.desktopFocusMode";
export const MEMO_LIST_DENSITY_STORAGE_KEY = "edgeever.memoListDensity";
export const MEMO_LIST_WIDTH_STORAGE_KEY = "edgeever.memoListWidth";
export const NOTEBOOK_SORT_STORAGE_KEY = "edgeever.notebookSort";
@@ -250,6 +251,22 @@ export const writeImageCompressionPreference = (enabled: boolean) => {
}
};
export const readDesktopFocusModePreference = () => {
try {
return window.localStorage.getItem(DESKTOP_FOCUS_MODE_STORAGE_KEY) === "true";
} catch {
return false;
}
};
export const writeDesktopFocusModePreference = (enabled: boolean) => {
try {
window.localStorage.setItem(DESKTOP_FOCUS_MODE_STORAGE_KEY, enabled ? "true" : "false");
} catch {
// Local storage can be unavailable in private or restricted browser contexts.
}
};
export const readMemoListDensityPreference = (): MemoListDensity => {
try {
const density = window.localStorage.getItem(MEMO_LIST_DENSITY_STORAGE_KEY);
+2
View File
@@ -467,6 +467,8 @@ export const enUS = {
backToList: "Back to list",
previousMemo: "Previous note",
nextMemo: "Next note",
enterFocusMode: "Enter focus mode",
exitFocusMode: "Exit focus mode",
updatedAt: "Updated {{time}}",
done: "Done",
uploadAttachment: "Upload attachment",
+2
View File
@@ -467,6 +467,8 @@ export const zhCN = {
backToList: "返回列表",
previousMemo: "上一条笔记",
nextMemo: "下一条笔记",
enterFocusMode: "进入专注模式",
exitFocusMode: "退出专注模式",
updatedAt: "更新于 {{time}}",
done: "完成",
uploadAttachment: "上传附件",