diff --git a/src/renderer/App.vue b/src/renderer/App.vue index cf9fc778..135d3bb0 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -6,6 +6,10 @@ import useFont from "@/renderer/hooks/useFont"; import useOtherConfig from "@/renderer/hooks/useOtherConfig"; import { isShowOutline, toggleShowOutline } from "@/renderer/hooks/useOutline"; import { useSaveConfirmDialog } from "@/renderer/hooks/useSaveConfirmDialog"; +import { + createSidebarWidthController, + SIDEBAR_DEFAULT_CSS, +} from "@/renderer/hooks/useSidebarWidth"; import useSourceCode from "@/renderer/hooks/useSourceCode"; import useSpellCheck from "@/renderer/hooks/useSpellCheck"; import useTab from "@/renderer/hooks/useTab"; @@ -14,6 +18,12 @@ import useUiLoading from "@/renderer/hooks/useUiLoading"; import { useUpdateDialog } from "@/renderer/hooks/useUpdateDialog"; import useWorkSpace from "@/renderer/hooks/useWorkSpace"; import { shouldAutoLoadWorkspace } from "@/renderer/utils/workspacePath"; +import { + getSidebarContextKey, + readSidebarVisibility, + resolveSidebarVisibility, + writeSidebarVisibility, +} from "@/renderer/utils/sidebarVisibility"; import SaveConfirmDialog from "./components/dialogs/SaveConfirmDialog.vue"; import UpdateConfirmDialog from "./components/dialogs/UpdateConfirmDialog.vue"; import MilkupEditor from "./components/editor/MilkupEditor.vue"; @@ -30,8 +40,8 @@ const { init: initTheme } = useTheme(); const { isLoading, loadingMessage } = useUiLoading(); const { init: initFont } = useFont(); const { init: initOtherConfig } = useOtherConfig(); -const { config } = useConfig(); -const { openWorkSpaceByPath } = useWorkSpace(); +const { config, setConf } = useConfig(); +const { openWorkSpaceByPath, watchedDirPath } = useWorkSpace(); const { isShowSource } = useSourceCode(); // 用于控制大纲显示 const { init: initSpellCheck } = useSpellCheck(); const { @@ -118,16 +128,119 @@ window.electronAPI.on("update:available", onUpdateAvailable); // 监听手动检查的更新可用事件 (Manual Check from About) import { onMounted, onUnmounted, ref, watch, nextTick, computed } from "vue"; + // 大纲侧边栏两阶段动画状态机 // closed: 隐藏 | opening: transform 滑入动画 | open: flex 正常布局 | closing-prep: 切回 transform 定位 | closing: transform 滑出动画 type OutlineState = "closed" | "opening" | "open" | "closing-prep" | "closing"; -const initialOutlineVisible = Boolean(config.value.workspace?.autoExpandSidebar); +function sidebarVisibilityForContext(contextKey: string): boolean { + return resolveSidebarVisibility( + readSidebarVisibility(contextKey), + Boolean(config.value.workspace?.autoExpandSidebar) + ); +} + +const initialSidebarContextKey = getSidebarContextKey( + watchedDirPath.value ?? config.value.workspace?.startupPath +); +let activeSidebarContextKey = initialSidebarContextKey; +let applyingSidebarContext = false; +const initialOutlineVisible = sidebarVisibilityForContext(initialSidebarContextKey); toggleShowOutline(initialOutlineVisible); const outlineState = ref(initialOutlineVisible ? "open" : "closed"); const editorAreaRef = ref(null); +const sidebarWidthController = createSidebarWidthController({ + getContainerWidth: () => editorAreaRef.value?.getBoundingClientRect().width ?? 0, + getPreferredWidth: () => config.value.workspace?.sidebarWidth, + setPreferredWidth: (width) => { + setConf("workspace", { + ...config.value.workspace, + sidebarWidth: width, + }); + }, +}); +const savedSidebarWidth = config.value.workspace?.sidebarWidth; +const sidebarWidth = ref( + typeof savedSidebarWidth === "number" && Number.isFinite(savedSidebarWidth) + ? savedSidebarWidth + : null +); +const sidebarResizing = ref(false); +const editorAreaStyle = computed(() => ({ + "--sidebar-width": sidebarWidth.value === null ? SIDEBAR_DEFAULT_CSS : `${sidebarWidth.value}px`, +})); + +function syncSidebarWidth() { + if (!sidebarResizing.value) { + sidebarWidth.value = sidebarWidthController.refresh(); + } +} + +function addSidebarResizeListeners() { + window.addEventListener("pointermove", moveSidebarResize); + window.addEventListener("pointerup", finishSidebarResize); + window.addEventListener("pointercancel", finishSidebarResize); + window.addEventListener("blur", finishSidebarResize); +} + +function removeSidebarResizeListeners() { + window.removeEventListener("pointermove", moveSidebarResize); + window.removeEventListener("pointerup", finishSidebarResize); + window.removeEventListener("pointercancel", finishSidebarResize); + window.removeEventListener("blur", finishSidebarResize); +} + +function startSidebarResize(event: PointerEvent) { + if (event.button !== 0) return; + event.preventDefault(); + const handle = event.currentTarget as HTMLElement | null; + try { + handle?.setPointerCapture(event.pointerId); + } catch { + // Window-level listeners remain the fallback when pointer capture is unavailable. + } + sidebarWidthController.beginResize(event.clientX); + sidebarResizing.value = true; + sidebarWidth.value = sidebarWidthController.getWidth(); + addSidebarResizeListeners(); +} + +function moveSidebarResize(event: PointerEvent) { + if (!sidebarResizing.value) return; + event.preventDefault(); + sidebarWidth.value = sidebarWidthController.resizeTo(event.clientX); +} + +function finishSidebarResize() { + if (!sidebarResizing.value) return; + sidebarResizing.value = false; + sidebarWidthController.endResize(); + sidebarWidth.value = sidebarWidthController.getWidth(); + removeSidebarResizeListeners(); +} + const outlineClass = computed(() => `outline-${outlineState.value}`); +watch( + isShowOutline, + (visible) => { + if (!applyingSidebarContext) { + writeSidebarVisibility(activeSidebarContextKey, visible); + } + }, + { flush: "sync" } +); + +watch(watchedDirPath, (workspacePath) => { + const nextSidebarContextKey = getSidebarContextKey(workspacePath); + if (nextSidebarContextKey === activeSidebarContextKey) return; + + activeSidebarContextKey = nextSidebarContextKey; + applyingSidebarContext = true; + toggleShowOutline(sidebarVisibilityForContext(nextSidebarContextKey)); + applyingSidebarContext = false; +}); + watch(isShowOutline, async (val) => { if (val) { outlineState.value = "opening"; @@ -150,6 +263,7 @@ function onOutlineTransitionEnd(e: TransitionEvent) { } onMounted(() => { + void nextTick().then(syncSidebarWidth); initTheme(); initFont(); initOtherConfig(); @@ -164,9 +278,15 @@ onMounted(() => { } emitter.on("update:available", onUpdateAvailable); }); +watch( + () => config.value.workspace?.sidebarWidth, + () => syncSidebarWidth() +); + onUnmounted(() => { emitter.off("update:available", onUpdateAvailable); emitter.off("tab:close-confirm", handleTabCloseConfirm); + removeSidebarResizeListeners(); }); // Reuse safe close logic @@ -222,9 +342,10 @@ const handleInstall = async () => {
-
+
+
@@ -295,7 +416,7 @@ const handleInstall = async () => { position: absolute; left: 0; top: 0; - width: 25%; + width: var(--sidebar-width); height: 100%; z-index: 10; transform: translateX(-100%); @@ -306,9 +427,38 @@ const handleInstall = async () => { opacity 0.2s ease; } + .sidebar-resize-handle { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 20; + width: 10px; + cursor: ew-resize; + touch-action: none; + background: transparent; + + &::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + right: -1px; + width: 2px; + background: var(--primary-color); + opacity: 0.35; + transition: opacity 0.15s ease; + } + + &:hover::after { + opacity: 1; + } + } + .editorBox { flex: 1; width: 100%; + cursor: default; transition: transform 0.2s ease; } @@ -320,7 +470,7 @@ const handleInstall = async () => { pointer-events: auto; } .editorBox { - transform: translateX(25%); + transform: translateX(var(--sidebar-width)); } } @@ -352,7 +502,7 @@ const handleInstall = async () => { } .editorBox { width: 100%; - transform: translateX(25%); + transform: translateX(var(--sidebar-width)); transition: none; } } diff --git a/src/renderer/components/settings/WorkspaceSetting.vue b/src/renderer/components/settings/WorkspaceSetting.vue index d8c7a4e5..f220be7e 100644 --- a/src/renderer/components/settings/WorkspaceSetting.vue +++ b/src/renderer/components/settings/WorkspaceSetting.vue @@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from "vue"; import AppIcon from "@/renderer/components/ui/AppIcon.vue"; import { Switch } from "@renderer/components/ui/switch"; import { useConfig } from "@/renderer/hooks/useConfig"; +import { isShowOutline, toggleShowOutline } from "@/renderer/hooks/useOutline"; import useWorkSpace from "@/renderer/hooks/useWorkSpace"; import { isAbsoluteLocalPath } from "@/renderer/utils/workspacePath"; @@ -10,7 +11,6 @@ const { config, setConf } = useConfig(); const { watchedDirPath, openWorkSpaceByPath } = useWorkSpace(); const startupPath = computed(() => config.value.workspace?.startupPath ?? ""); -const autoExpandSidebar = computed(() => config.value.workspace?.autoExpandSidebar ?? false); const isPathExists = ref(true); async function handleSelectWorkspace() { @@ -43,13 +43,6 @@ function clearWorkspacePath() { }); } -function updateAutoExpandSidebar(value: boolean) { - setConf("workspace", { - ...config.value.workspace, - autoExpandSidebar: value, - }); -} - async function checkWorkspacePath() { if (!startupPath.value) { isPathExists.value = true; @@ -103,10 +96,13 @@ onMounted(() => { 左侧边栏
+

+ 关闭或打开侧边栏后会立即生效;关闭程序后,下次打开时仍保持最后的选择。 +

@@ -201,6 +197,13 @@ onMounted(() => { .switch-wrapper { padding-top: 8px; + + .setting-hint { + margin: 6px 0 0; + color: var(--text-color-3); + font-size: 12px; + line-height: 1.5; + } } .path-tip { diff --git a/src/renderer/hooks/useConfig.ts b/src/renderer/hooks/useConfig.ts index 9fa9d684..76d4e9bb 100644 --- a/src/renderer/hooks/useConfig.ts +++ b/src/renderer/hooks/useConfig.ts @@ -32,6 +32,7 @@ interface AppConfig extends Record { sortBy: "name" | "mtime"; startupPath: string; autoExpandSidebar: boolean; + sidebarWidth: number | null; }; } @@ -57,6 +58,7 @@ const defaultConfig: AppConfig = { sortBy: "name", startupPath: "", autoExpandSidebar: false, + sidebarWidth: null, }, }; diff --git a/src/renderer/hooks/useSidebarWidth.ts b/src/renderer/hooks/useSidebarWidth.ts new file mode 100644 index 00000000..ee2da25f --- /dev/null +++ b/src/renderer/hooks/useSidebarWidth.ts @@ -0,0 +1,83 @@ +const SIDEBAR_DEFAULT_RATIO = 0.25; +export const SIDEBAR_DEFAULT_CSS = `${SIDEBAR_DEFAULT_RATIO * 100}%`; +const SIDEBAR_MIN_WIDTH = 200; +const SIDEBAR_MAX_WIDTH = 600; +const SIDEBAR_MAX_RATIO = 0.5; + +interface SidebarWidthBounds { + min: number; + max: number; +} + +interface SidebarWidthControllerOptions { + getContainerWidth: () => number; + getPreferredWidth: () => number | null | undefined; + setPreferredWidth: (width: number) => void; +} + +interface SidebarWidthController { + getBounds: () => SidebarWidthBounds; + getWidth: () => number; + beginResize: (clientX: number) => void; + resizeTo: (clientX: number) => number; + endResize: () => void; + refresh: () => number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(value, max)); +} + +export function createSidebarWidthController( + options: SidebarWidthControllerOptions +): SidebarWidthController { + let width = 0; + let resizing = false; + let resizeStartX = 0; + let resizeStartWidth = 0; + + function getBounds(): SidebarWidthBounds { + const containerWidth = Math.max(0, options.getContainerWidth()); + const max = Math.min(SIDEBAR_MAX_WIDTH, Math.floor(containerWidth * SIDEBAR_MAX_RATIO)); + return { + min: Math.min(SIDEBAR_MIN_WIDTH, max), + max, + }; + } + + function resolveWidth(): number { + const bounds = getBounds(); + const preferredWidth = options.getPreferredWidth(); + const requestedWidth = + typeof preferredWidth === "number" && Number.isFinite(preferredWidth) + ? preferredWidth + : options.getContainerWidth() * SIDEBAR_DEFAULT_RATIO; + return clamp(requestedWidth, bounds.min, bounds.max); + } + + width = resolveWidth(); + + return { + getBounds, + getWidth: () => width, + beginResize(clientX) { + resizeStartX = clientX; + resizeStartWidth = width; + resizing = true; + }, + resizeTo(clientX) { + if (!resizing) return width; + width = clamp(resizeStartWidth + clientX - resizeStartX, getBounds().min, getBounds().max); + return width; + }, + endResize() { + if (!resizing) return; + resizing = false; + options.setPreferredWidth(width); + }, + refresh() { + width = resolveWidth(); + return width; + }, + }; +} diff --git a/src/renderer/utils/sidebarVisibility.ts b/src/renderer/utils/sidebarVisibility.ts new file mode 100644 index 00000000..68770fb0 --- /dev/null +++ b/src/renderer/utils/sidebarVisibility.ts @@ -0,0 +1,47 @@ +export const STANDALONE_SIDEBAR_CONTEXT = "__standalone__"; + +const SIDEBAR_VISIBILITY_STORAGE_PREFIX = "milkup-sidebar-visibility:"; + +type SidebarVisibilityStorage = Pick; + +export function getSidebarContextKey(pathValue?: string | null): string { + const value = pathValue?.trim(); + if (!value) return STANDALONE_SIDEBAR_CONTEXT; + + const isWindowsPath = /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith("\\\\"); + let normalized = value.replace(/[\\/]+$/, ""); + if (!normalized) return "/"; + if (/^[a-zA-Z]:$/.test(normalized)) normalized += "/"; + + return isWindowsPath ? normalized.replace(/\\/g, "/").toLowerCase() : normalized; +} + +export function resolveSidebarVisibility( + savedVisibility: unknown, + fallbackVisibility: boolean +): boolean { + return typeof savedVisibility === "boolean" ? savedVisibility : fallbackVisibility; +} + +export function readSidebarVisibility( + contextKey: string, + storage: SidebarVisibilityStorage = localStorage +): boolean | undefined { + const value = storage.getItem( + `${SIDEBAR_VISIBILITY_STORAGE_PREFIX}${encodeURIComponent(contextKey)}` + ); + if (value === "true") return true; + if (value === "false") return false; + return undefined; +} + +export function writeSidebarVisibility( + contextKey: string, + visible: boolean, + storage: SidebarVisibilityStorage = localStorage +): void { + storage.setItem( + `${SIDEBAR_VISIBILITY_STORAGE_PREFIX}${encodeURIComponent(contextKey)}`, + String(visible) + ); +} diff --git a/test/sidebar-visibility.test.mjs b/test/sidebar-visibility.test.mjs new file mode 100644 index 00000000..826621ad --- /dev/null +++ b/test/sidebar-visibility.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + getSidebarContextKey, + readSidebarVisibility, + resolveSidebarVisibility, + STANDALONE_SIDEBAR_CONTEXT, + writeSidebarVisibility, +} from "../src/renderer/utils/sidebarVisibility.ts"; + +function createStorage(initial = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem(key) { + return values.get(key) ?? null; + }, + setItem(key, value) { + values.set(key, value); + }, + values, + }; +} + +test("sidebar context keys distinguish workspaces and standalone mode", () => { + assert.equal(getSidebarContextKey("C:\\Project\\"), "c:/project"); + assert.equal(getSidebarContextKey("C:/Project///"), "c:/project"); + assert.equal(getSidebarContextKey("C:\\"), "c:/"); + assert.equal(getSidebarContextKey("/"), "/"); + assert.equal(getSidebarContextKey("/WorkSpace/"), "/WorkSpace"); + assert.equal(getSidebarContextKey(""), STANDALONE_SIDEBAR_CONTEXT); + assert.equal(getSidebarContextKey(null), STANDALONE_SIDEBAR_CONTEXT); +}); + +test("saved sidebar visibility wins over the startup fallback", () => { + assert.equal(resolveSidebarVisibility(false, true), false); + assert.equal(resolveSidebarVisibility(true, false), true); + assert.equal(resolveSidebarVisibility(undefined, true), true); + assert.equal(resolveSidebarVisibility("false", true), true); +}); + +test("workspace visibility writes stay independent", () => { + const storage = createStorage(); + + writeSidebarVisibility("project-a", false, storage); + writeSidebarVisibility("project-b", true, storage); + + assert.equal(readSidebarVisibility("project-a", storage), false); + assert.equal(readSidebarVisibility("project-b", storage), true); + assert.equal(readSidebarVisibility("project-c", storage), undefined); + + writeSidebarVisibility("project-a", true, storage); + assert.equal(readSidebarVisibility("project-a", storage), true); +}); diff --git a/test/sidebar-width.test.mjs b/test/sidebar-width.test.mjs new file mode 100644 index 00000000..363f2f4a --- /dev/null +++ b/test/sidebar-width.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createSidebarWidthController } from "../src/renderer/hooks/useSidebarWidth.ts"; + +test("sidebar width defaults to 25 percent and clamps to the allowed range", () => { + const controller = createSidebarWidthController({ + getContainerWidth: () => 1200, + getPreferredWidth: () => null, + setPreferredWidth: () => {}, + }); + + assert.deepEqual(controller.getBounds(), { min: 200, max: 600 }); + assert.equal(controller.getWidth(), 300); + + controller.beginResize(500); + assert.equal(controller.resizeTo(-1000), 200); + assert.equal(controller.resizeTo(2000), 600); + controller.endResize(); +}); + +test("sidebar width changes live and persists only when dragging ends", () => { + let preferredWidth = 320; + const writes = []; + const controller = createSidebarWidthController({ + getContainerWidth: () => 1200, + getPreferredWidth: () => preferredWidth, + setPreferredWidth: (width) => { + preferredWidth = width; + writes.push(width); + }, + }); + + assert.equal(controller.getWidth(), 320); + controller.beginResize(500); + assert.equal(controller.resizeTo(560), 380); + assert.equal(controller.getWidth(), 380); + assert.deepEqual(writes, []); + + controller.endResize(); + assert.equal(controller.getWidth(), 380); + assert.deepEqual(writes, [380]); + + const restoredController = createSidebarWidthController({ + getContainerWidth: () => 1200, + getPreferredWidth: () => preferredWidth, + setPreferredWidth: () => {}, + }); + assert.equal(restoredController.getWidth(), 380); +}); + +test("sidebar width adapts its bounds to a small editor area without changing the preference", () => { + let containerWidth = 400; + let preferredWidth = 320; + let writes = 0; + const controller = createSidebarWidthController({ + getContainerWidth: () => containerWidth, + getPreferredWidth: () => preferredWidth, + setPreferredWidth: () => { + writes += 1; + }, + }); + + assert.deepEqual(controller.getBounds(), { min: 200, max: 200 }); + assert.equal(controller.getWidth(), 200); + + containerWidth = 1200; + assert.equal(controller.refresh(), 320); + assert.equal(preferredWidth, 320); + assert.equal(writes, 0); +});