From 862238b01687b6ccc0e84901cf66d1ce683ac912 Mon Sep 17 00:00:00 2001 From: runningwaterpro Date: Fri, 25 Sep 2026 21:14:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=9C=A8=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=A0=8F=E7=BC=A9=E6=94=BE=E5=BD=93=E5=89=8D=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户现在可以在底部状态栏点击“−”、百分比或“+”,将当前标签的编辑内容在 50%~300% 之间缩放;点击百分比即可恢复到 100%。 - 每个已打开的标签独立保存倍率;新标签默认 100%,关闭标签或重启应用后重置 - 缩放边界只包住当前标签的编辑器内容层,不改变标题栏、侧边栏、标签栏和状态栏尺寸 - 缩放不会修改文档内容、文件路径、保存状态或撤销历史 - 状态栏按钮支持键盘聚焦,并在到达 50% 或 300% 边界时自动禁用 - 使用上游现有的 Milkup 编辑器实例和标签状态,不引入第二份缩放状态 本次 PR 只包含编辑区缩放,不包含快捷键、滚轮缩放或导出改造。 验证: - pnpm test:29 个测试通过 - pnpm build:通过 Related: runningwaterpro/milkup#13 --- .../components/editor/MilkupEditor.vue | 13 ++- src/renderer/components/menu/StatusBar.vue | 78 +++++++++++++++- src/renderer/hooks/useEditorZoom.ts | 10 ++ src/renderer/utils/editorZoom.ts | 59 ++++++++++++ src/types/tab.ts | 1 + test/editor-zoom.test.mjs | 91 +++++++++++++++++++ 6 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 src/renderer/hooks/useEditorZoom.ts create mode 100644 src/renderer/utils/editorZoom.ts create mode 100644 test/editor-zoom.test.mjs diff --git a/src/renderer/components/editor/MilkupEditor.vue b/src/renderer/components/editor/MilkupEditor.vue index 7ed50e29..587a4200 100644 --- a/src/renderer/components/editor/MilkupEditor.vue +++ b/src/renderer/components/editor/MilkupEditor.vue @@ -17,6 +17,7 @@ import { undo, redo } from "prosemirror-history"; import { uploadImage } from "@/renderer/services/api"; import { AIService } from "@/renderer/services/ai"; import { useAIConfig } from "@/renderer/hooks/useAIConfig"; +import useEditorZoom from "@/renderer/hooks/useEditorZoom"; import { useConfig } from "@/renderer/hooks/useConfig"; import useUiLoading from "@/renderer/hooks/useUiLoading"; import LoadingIcon from "@/renderer/components/ui/LoadingIcon.vue"; @@ -30,6 +31,8 @@ interface Props { } const props = defineProps(); +const { contentStyleFor } = useEditorZoom(); +const editorContentStyle = computed(() => contentStyleFor(props.tab)); const LARGE_DOCUMENT_CHAR_THRESHOLD = 200_000; const LARGE_DOCUMENT_LINE_THRESHOLD = 4_000; @@ -531,7 +534,9 @@ defineExpose({ :data-active="isActive ? 'true' : 'false'" >
-
+
+
+
@@ -557,6 +562,12 @@ defineExpose({ background: var(--background-color-1); } + .editor-zoom-surface { + min-height: 100%; + display: flex; + flex-direction: column; + } + .milkup-container { min-height: 100%; display: flex; diff --git a/src/renderer/components/menu/StatusBar.vue b/src/renderer/components/menu/StatusBar.vue index adbd6a63..9dbfd676 100644 --- a/src/renderer/components/menu/StatusBar.vue +++ b/src/renderer/components/menu/StatusBar.vue @@ -2,6 +2,7 @@ import { computed, ref } from "vue"; import AppIcon from "@/renderer/components/ui/AppIcon.vue"; import { toggleShowOutline } from "@/renderer/hooks/useOutline"; +import useEditorZoom from "@/renderer/hooks/useEditorZoom"; import useSourceCode from "@/renderer/hooks/useSourceCode"; const props = defineProps<{ @@ -15,6 +16,8 @@ const emit = defineEmits<{ }>(); const { isShowSource, toggleSourceCode } = useSourceCode(); +const { zoomPercent, canZoomOut, canZoomIn, canResetZoom, zoomOut, resetZoom, zoomIn } = + useEditorZoom(); const mode = ref<"chars" | "lines">("chars"); const displayText = computed(() => { @@ -90,7 +93,39 @@ window.electronAPI.on("view:toggleView", () => {
- {{ displayText }} +
+ {{ displayText }} +
+ + + +
+
@@ -122,6 +157,47 @@ window.electronAPI.on("view:toggleView", () => { } } +.right-section { + display: flex; + align-items: center; + gap: 8px; +} + +.zoomControls { + display: inline-flex; + align-items: center; + gap: 2px; + + button { + min-width: 24px; + height: 22px; + padding: 0 6px; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + font: inherit; + line-height: 1; + cursor: pointer; + + &:hover:not(:disabled) { + background: var(--hover-color); + } + + &:disabled { + opacity: 0.4; + cursor: default; + } + } + + .zoomValue { + min-width: 48px; + color: var(--text-color-3); + font-size: 12px; + font-variant-numeric: tabular-nums; + } +} + .statusBarText { font-size: 12px; margin: 2px 0; diff --git a/src/renderer/hooks/useEditorZoom.ts b/src/renderer/hooks/useEditorZoom.ts new file mode 100644 index 00000000..5559833e --- /dev/null +++ b/src/renderer/hooks/useEditorZoom.ts @@ -0,0 +1,10 @@ +import { createEditorZoomController } from "@/renderer/utils/editorZoom"; +import useTab from "./useTab"; + +let controller: ReturnType | undefined; + +export default function useEditorZoom() { + if (!controller) controller = createEditorZoomController(useTab().currentTab); + + return controller; +} diff --git a/src/renderer/utils/editorZoom.ts b/src/renderer/utils/editorZoom.ts new file mode 100644 index 00000000..d7e988f8 --- /dev/null +++ b/src/renderer/utils/editorZoom.ts @@ -0,0 +1,59 @@ +import type { Ref } from "vue"; +import type { Tab } from "@/types/tab"; +import { computed } from "vue"; + +export const DEFAULT_ZOOM_PERCENT = 100; +export const MIN_ZOOM_PERCENT = 50; +export const MAX_ZOOM_PERCENT = 300; +export const ZOOM_STEP_PERCENT = 10; + +function getZoomPercent(tab: Tab | null | undefined): number { + return tab?.zoomPercent ?? DEFAULT_ZOOM_PERCENT; +} + +function getContentStyle(tab: Tab | null | undefined): { zoom: number } { + return { zoom: getZoomPercent(tab) / 100 }; +} + +export function createEditorZoomController(activeTab: Readonly>) { + const hasActiveTab = computed(() => activeTab.value !== null); + const zoomPercent = computed(() => getZoomPercent(activeTab.value)); + const contentStyle = computed(() => getContentStyle(activeTab.value)); + const canZoomOut = computed(() => hasActiveTab.value && zoomPercent.value > MIN_ZOOM_PERCENT); + const canZoomIn = computed(() => hasActiveTab.value && zoomPercent.value < MAX_ZOOM_PERCENT); + const canResetZoom = computed( + () => hasActiveTab.value && zoomPercent.value !== DEFAULT_ZOOM_PERCENT + ); + + function setZoomPercent(value: number) { + const tab = activeTab.value; + if (!tab) return; + + tab.zoomPercent = Math.min(MAX_ZOOM_PERCENT, Math.max(MIN_ZOOM_PERCENT, value)); + } + + function zoomIn() { + setZoomPercent(zoomPercent.value + ZOOM_STEP_PERCENT); + } + + function zoomOut() { + setZoomPercent(zoomPercent.value - ZOOM_STEP_PERCENT); + } + + function resetZoom() { + setZoomPercent(DEFAULT_ZOOM_PERCENT); + } + + return { + hasActiveTab, + zoomPercent, + contentStyle, + contentStyleFor: getContentStyle, + canZoomOut, + canZoomIn, + canResetZoom, + zoomIn, + zoomOut, + resetZoom, + }; +} diff --git a/src/types/tab.ts b/src/types/tab.ts index d80371f8..1c2dcd29 100644 --- a/src/types/tab.ts +++ b/src/types/tab.ts @@ -5,6 +5,7 @@ export interface Tab { content: string; originalContent: string; isModified: boolean; + zoomPercent?: number; scrollRatio?: number; readOnly: boolean; milkdownCursorOffset?: number | null; diff --git a/test/editor-zoom.test.mjs b/test/editor-zoom.test.mjs new file mode 100644 index 00000000..b2896aad --- /dev/null +++ b/test/editor-zoom.test.mjs @@ -0,0 +1,91 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ref } from "vue"; +import { createEditorZoomController } from "../src/renderer/utils/editorZoom.ts"; + +function createTab(overrides = {}) { + return { + id: "tab-1", + name: "Note", + filePath: null, + content: "# Note", + originalContent: "# Note", + isModified: false, + readOnly: false, + zoomPercent: 100, + ...overrides, + }; +} + +test("editor zoom starts at the 100% baseline", () => { + const activeTab = ref(createTab()); + const controller = createEditorZoomController(activeTab); + + assert.equal(controller.zoomPercent.value, 100); + assert.deepEqual(controller.contentStyle.value, { zoom: 1 }); +}); + +test("editor zoom changes in ten-percent steps and resets to 100%", () => { + const activeTab = ref(createTab()); + const controller = createEditorZoomController(activeTab); + + controller.zoomIn(); + assert.equal(controller.zoomPercent.value, 110); + assert.deepEqual(controller.contentStyle.value, { zoom: 1.1 }); + + controller.zoomOut(); + controller.zoomOut(); + assert.equal(controller.zoomPercent.value, 90); + + controller.resetZoom(); + assert.equal(controller.zoomPercent.value, 100); +}); + +test("editor zoom clamps to 50–300% and exposes action availability", () => { + const activeTab = ref(createTab()); + const controller = createEditorZoomController(activeTab); + + for (let index = 0; index < 10; index += 1) controller.zoomOut(); + assert.equal(controller.zoomPercent.value, 50); + assert.equal(controller.canZoomOut.value, false); + assert.equal(controller.canZoomIn.value, true); + + for (let index = 0; index < 30; index += 1) controller.zoomIn(); + assert.equal(controller.zoomPercent.value, 300); + assert.equal(controller.canZoomIn.value, false); + assert.equal(controller.canZoomOut.value, true); +}); + +test("editor zoom stays isolated per tab without changing document state", () => { + const firstTab = createTab({ id: "tab-1", filePath: "D:/notes/note.md" }); + const secondTab = createTab({ + id: "tab-2", + name: "Other", + content: "Other", + originalContent: "Other", + }); + const activeTab = ref(firstTab); + const controller = createEditorZoomController(activeTab); + const firstTabBeforeZoom = { ...firstTab }; + + controller.zoomIn(); + assert.equal(firstTab.zoomPercent, 110); + assert.equal(secondTab.zoomPercent, 100); + assert.deepEqual( + { ...firstTab, zoomPercent: firstTabBeforeZoom.zoomPercent }, + firstTabBeforeZoom + ); + assert.deepEqual(controller.contentStyleFor(firstTab), { zoom: 1.1 }); + + activeTab.value = secondTab; + assert.equal(controller.zoomPercent.value, 100); + + activeTab.value = firstTab; + assert.equal(controller.zoomPercent.value, 110); + + activeTab.value = null; + controller.zoomIn(); + assert.equal(controller.hasActiveTab.value, false); + assert.equal(controller.canZoomIn.value, false); + assert.equal(firstTab.zoomPercent, 110); +});