feat: add desktop focus mode (#57)
This commit is contained in:
1 parent
ed8a8f091f
commit
da4d9155d9
9 files changed
+150
-13
No files matched your search
@@ -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
@@ -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 源码与富文本视图切换。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -467,6 +467,8 @@ export const zhCN = {
|
||||
backToList: "返回列表",
|
||||
previousMemo: "上一条笔记",
|
||||
nextMemo: "下一条笔记",
|
||||
enterFocusMode: "进入专注模式",
|
||||
exitFocusMode: "退出专注模式",
|
||||
updatedAt: "更新于 {{time}}",
|
||||
done: "完成",
|
||||
uploadAttachment: "上传附件",
|
||||
|
||||
Reference in new issue
Block a user