From d9559fc2f7f4881036398f5963919f2c037c6d24 Mon Sep 17 00:00:00 2001 From: runningwaterpro Date: Sat, 26 Sep 2026 15:35:55 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E7=BC=96=E8=BE=91=E5=8C=BA?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E6=94=AF=E6=8C=81=E7=BC=A9=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 底部状态栏新增 [−] [100%] [+],也可以用快捷键和触控板调整当前标签的编辑内容 显示比例,范围 50%~300%。 怎么用 - Ctrl/Cmd + Shift + > 放大 - Ctrl/Cmd + Shift + < 缩小 - Ctrl/Cmd + Shift + D 还原到 100% - Ctrl/Cmd + 滚轮 按滚动量连续缩放 - 点状态栏的百分比 还原到 100% - 滚轮(不按 Ctrl) 保持原有滚动 三个快捷键可在「设置 → 快捷键 → 编辑器」中修改,改完立即生效。 解决的实际问题 - 放大后阅读位置不再跳走。原先按文档百分比还原滚动位置,倍率越大偏差越大, 300% 时能差出七千多像素(约十二屏),表现为选中的文字往上跑、最终移出视口。 改为按选区距视口顶部的像素位置锚定,同一组场景下偏差为 0。 - 连续缩放不再累积偏移。滚轮和连按会连续触发缩放,原先每轮的补偿还挂在 动画帧上,下一轮内容已缩放而滚动未跟上,捕获到的锚点是错的。实测连续 二十档缩到 300%,偏差从 12320px 降到 0。 - Ctrl+滚轮按累积滚动量线性响应。此前用时间窗口判断手势边界,按住 Ctrl 连续滚动会出现停顿感,改为累积量后与 Chrome / VS Code 行为一致。 - 100% 时百分比按钮保持正常外观。「不需要」不等于「不能」,此前用 disabled 表达导致灰显。 - 导出的 HTML / Word / PDF 不含缩放信息,排版与 100% 时一致。 行为边界 - 只作用于编辑区内容层。标题栏、侧边栏、标签栏、状态栏保持原尺寸。 - 代码块的复制按钮和语言/模式选择器在任何倍率下都是原尺寸。 - 每个已打开的标签独立保存倍率,新标签为 100%,关闭标签或重启后重置。 - 不改变 Markdown、文件路径、修改状态、撤销历史和剪贴板内容。 - 源码模式与富文本模式共用同一个标签倍率,状态栏显示同一个百分比。 - 只读文档可以缩放,且不会被改成可编辑。 - 达到 50% 或 300% 时对应按钮禁用。 快捷键选键 - 与已有的两组绑定错开:Ctrl+0 属于「设为段落」,Ctrl+Shift+- 属于 「分割线」,均未改动。 - > 和 < 对应键盘上的 . 和 , 键,是 Word / Excel / Google Docs 的标准 缩放键位。 - 「还原」未用 Ctrl+Shift+0:实测该组合在本应用的快捷键录制中录不进, 按下 Ctrl、Shift 之后收不到主键事件。未采用。 - 三个键位在现有 25 条默认快捷键与菜单 accelerator 中均未被占用。 实现说明 - 缩放快捷键不注册到 ProseMirror keymap,由渲染进程的全局监听处理。keymap 挂在编辑器 DOM 上、仅在编辑区获得焦点时响应,而缩放只改变视图呈现、不修改 文档,窗口有焦点就应当可用。这三条在 action-commands 中没有对应的 command, keymap 构建时会被自动跳过,core 层无需改动。 - 复用既有的快捷键存储、冲突检测、录制界面和显示格式,没有新增配置项。 顺带修复 快捷键录制此前对每一个 keydown 都调用 preventDefault,在 Windows 上会连带 阻止主键事件产生,导致某些组合无法录入。改为只在真正决定结果(录成、清除、 取消)时阻止默认。此问题影响设置页全部条目,与缩放无关,但位于同一条链路。 主键比对补上大小写与 Shift 变体归一:配置存小写 d 而按 Shift+d 时 event.key 是大写 D;配置存 0 而按 Ctrl+Shift+0 时 event.key 是右括号。 验证 - pnpm test:44 个测试通过 - pnpm build:通过 - Electron 37 实测 50%/100%/150%/200%/300% 五档:正文按倍率缩放、外壳与辅助 工具尺寸恒定、滚动位置恢复偏差 0px - 人工验证:按钮、快捷键、滚轮、阅读位置、源码模式、多标签隔离、复制撤销 保存、只读文档、PDF 导出 --- src/core/keymap/shortcut-registry.ts | 25 ++ src/core/keymap/types.ts | 5 +- .../components/editor/MilkupEditor.vue | 121 +++++++-- src/renderer/components/menu/StatusBar.vue | 36 ++- .../components/settings/ShortcutPage.vue | 70 ++++-- src/renderer/hooks/useEditorZoom.ts | 48 +++- src/renderer/hooks/useShortcutConfig.ts | 38 ++- src/renderer/utils/editorZoom.ts | 90 ++++++- src/renderer/utils/exports.ts | 3 + test/editor-zoom.test.mjs | 233 +++++++++++++++++- 10 files changed, 600 insertions(+), 69 deletions(-) diff --git a/src/core/keymap/shortcut-registry.ts b/src/core/keymap/shortcut-registry.ts index c5965ff1..1b2e9ef8 100644 --- a/src/core/keymap/shortcut-registry.ts +++ b/src/core/keymap/shortcut-registry.ts @@ -128,6 +128,31 @@ export const DEFAULT_SHORTCUTS: ShortcutDefinition[] = [ }, { id: "undo", label: "撤销", category: "editor", key: "Mod-z", defaultKey: "Mod-z" }, { id: "redo", label: "重做", category: "editor", key: "Mod-y", defaultKey: "Mod-y" }, + + // 编辑区缩放。这三条在 action-commands 里没有对应 command, + // 因此不会进 ProseMirror keymap,由渲染进程的全局监听处理, + // 窗口有焦点即生效,不需要先点编辑区。 + { + id: "zoomIn", + label: "放大", + category: "editor", + key: "Mod-Shift->", + defaultKey: "Mod-Shift->", + }, + { + id: "zoomOut", + label: "缩小", + category: "editor", + key: "Mod-Shift-<", + defaultKey: "Mod-Shift-<", + }, + { + id: "resetZoom", + label: "还原", + category: "editor", + key: "Mod-Shift-d", + defaultKey: "Mod-Shift-d", + }, ]; /** 分类中文名映射 */ diff --git a/src/core/keymap/types.ts b/src/core/keymap/types.ts index 6b8b2d87..8b2a3f84 100644 --- a/src/core/keymap/types.ts +++ b/src/core/keymap/types.ts @@ -28,7 +28,10 @@ export type ShortcutActionId = | "switchNextTab" | "switchTabByNumber" | "undo" - | "redo"; + | "redo" + | "zoomIn" + | "zoomOut" + | "resetZoom"; /** 快捷键分类 */ export type ShortcutCategory = "inline" | "block" | "insert" | "editor" | "app"; diff --git a/src/renderer/components/editor/MilkupEditor.vue b/src/renderer/components/editor/MilkupEditor.vue index 5d7dc037..4f2cae1b 100644 --- a/src/renderer/components/editor/MilkupEditor.vue +++ b/src/renderer/components/editor/MilkupEditor.vue @@ -31,7 +31,7 @@ interface Props { } const props = defineProps(); -const { contentStyleFor } = useEditorZoom(); +const { contentStyleFor, handleWheel } = useEditorZoom(); const editorContentStyle = computed(() => contentStyleFor(props.tab)); const LARGE_DOCUMENT_CHAR_THRESHOLD = 200_000; @@ -163,18 +163,82 @@ function scheduleNewlyLoadedCleanup() { // 更新滚动比例(rAF 节流) let scrollRafId: number | null = null; +let zoomScrollRafId: number | null = null; + +function readScrollRatio(target: HTMLElement): number { + const scrollHeight = target.scrollHeight - target.clientHeight; + return scrollHeight === 0 ? 0 : target.scrollTop / scrollHeight; +} + +function writeScrollRatio(target: HTMLElement, ratio: number) { + const scrollHeight = target.scrollHeight - target.clientHeight; + target.scrollTop = scrollHeight === 0 ? 0 : ratio * scrollHeight; +} + function updateScrollRatio(e: Event) { if (scrollRafId !== null) return; const target = e.target as HTMLElement; scrollRafId = requestAnimationFrame(() => { scrollRafId = null; - const scrollTop = target.scrollTop; - const scrollHeight = target.scrollHeight - target.clientHeight; - const ratio = scrollHeight === 0 ? 0 : scrollTop / scrollHeight; - props.tab.scrollRatio = ratio; + props.tab.scrollRatio = readScrollRatio(target); }); } +/** + * 缩放前记下阅读锚点。 + * offset 是选区距视口顶部的像素;拿不到就退回到文档比例。 + * 按文档比例还原会随倍率放大误差:长文档缩到 300% 时能差出好几屏。 + */ +function captureZoomAnchor() { + const scrollView = scrollViewRef.value; + if (!scrollView) return null; + + const ratio = readScrollRatio(scrollView); + if (!editor) return { offset: null, ratio }; + + try { + const { top } = editor.view.coordsAtPos(editor.view.state.selection.from); + const rect = scrollView.getBoundingClientRect(); + // 选区本来就不在视口内时不干预,避免把用户拉走 + return { offset: top > rect.top && top < rect.bottom ? top - rect.top : null, ratio }; + } catch { + return { offset: null, ratio }; + } +} + +/** 把锚点补偿落实到当前滚动位置 */ +function applyZoomAnchor(anchor: ReturnType) { + const scrollView = scrollViewRef.value; + if (!scrollView || !anchor) return; + + if (anchor.offset === null || !editor) { + writeScrollRatio(scrollView, anchor.ratio); + return; + } + try { + const { top } = editor.view.coordsAtPos(editor.view.state.selection.from); + scrollView.scrollTop += top - scrollView.getBoundingClientRect().top - anchor.offset; + } catch { + writeScrollRatio(scrollView, anchor.ratio); + } +} + +// 尚未补偿的锚点。滚轮和连按会连续触发 watch, +// 上一轮的补偿可能还挂在 rAF 上(此时 DOM 已更新、滚动还没跟上)。 +type ZoomAnchor = ReturnType; +let pendingZoomAnchor: ZoomAnchor = null; + +/** 立刻落实待补偿的锚点。必须在捕获新锚点之前调用,否则捕获到的是错乱状态 */ +function flushZoomAnchor() { + if (zoomScrollRafId !== null) { + cancelAnimationFrame(zoomScrollRafId); + zoomScrollRafId = null; + } + const anchor = pendingZoomAnchor; + pendingZoomAnchor = null; + applyZoomAnchor(anchor); +} + // 预处理内容(主进程已完成图片路径转换,这里仅处理空格编码供编辑器渲染) function preprocessContent(content: string): string { if (!content) return ""; @@ -333,10 +397,7 @@ function createEditorInstance() { // 恢复滚动位置 nextTick(() => { if (scrollViewRef.value) { - const scrollRatio = props.tab.scrollRatio ?? 0; - const targetScrollTop = - scrollRatio * (scrollViewRef.value.scrollHeight - scrollViewRef.value.clientHeight); - scrollViewRef.value.scrollTop = targetScrollTop; + writeScrollRatio(scrollViewRef.value, props.tab.scrollRatio ?? 0); } }); } @@ -363,10 +424,7 @@ function syncEditorFromTab(content: string) { nextTick(() => { if (scrollViewRef.value) { - const scrollRatio = props.tab.scrollRatio ?? 0; - const targetScrollTop = - scrollRatio * (scrollViewRef.value.scrollHeight - scrollViewRef.value.clientHeight); - scrollViewRef.value.scrollTop = targetScrollTop; + writeScrollRatio(scrollViewRef.value, props.tab.scrollRatio ?? 0); } }); } finally { @@ -401,6 +459,8 @@ onUnmounted(() => { isEditorInitializing.value = false; if (newlyLoadedTimer) clearTimeout(newlyLoadedTimer); if (outlineTimer) clearTimeout(outlineTimer); + if (zoomScrollRafId !== null) cancelAnimationFrame(zoomScrollRafId); + pendingZoomAnchor = null; emitter.off("sourceView:toggle", handleSourceViewToggle); emitter.off("outline:scrollTo", handleOutlineScrollTo); emitter.off("editor:reload", handleEditorReload); @@ -496,6 +556,28 @@ watch( } ); +// 缩放前后保持当前阅读位置 +watch( + () => props.tab.zoomPercent, + () => { + // watch 是 pre-flush,此刻 DOM 还是上一次的倍率,先把上一轮补偿结清 + flushZoomAnchor(); + pendingZoomAnchor = captureZoomAnchor(); + const anchor = pendingZoomAnchor; + + // 等 Vue patch 完样式、布局稳定后再补偿 + nextTick(() => { + zoomScrollRafId = requestAnimationFrame(() => { + zoomScrollRafId = null; + if (pendingZoomAnchor === anchor) { + pendingZoomAnchor = null; + applyZoomAnchor(anchor); + } + }); + }); + } +); + // 监听 tab.readOnly 变化 watch( () => props.tab.readOnly, @@ -537,7 +619,12 @@ defineExpose({ :data-tab-id="tab.id" :data-active="isActive ? 'true' : 'false'" > -
+
@@ -570,6 +657,12 @@ defineExpose({ min-height: 100%; display: flex; flex-direction: column; + + /* 编辑区辅助工具保持固定尺寸,不跟随内容缩放 */ + :deep(.milkup-code-block-copy-btn), + :deep(.milkup-custom-select) { + zoom: var(--editor-zoom-inverse, 1); + } } .milkup-container { diff --git a/src/renderer/components/menu/StatusBar.vue b/src/renderer/components/menu/StatusBar.vue index 9dbfd676..8d6516be 100644 --- a/src/renderer/components/menu/StatusBar.vue +++ b/src/renderer/components/menu/StatusBar.vue @@ -2,7 +2,8 @@ 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 useEditorZoom, { installEditorZoomShortcuts } from "@/renderer/hooks/useEditorZoom"; +import { formatKeyForDisplay, useShortcutConfig } from "@/renderer/hooks/useShortcutConfig"; import useSourceCode from "@/renderer/hooks/useSourceCode"; const props = defineProps<{ @@ -16,9 +17,25 @@ const emit = defineEmits<{ }>(); const { isShowSource, toggleSourceCode } = useSourceCode(); -const { zoomPercent, canZoomOut, canZoomIn, canResetZoom, zoomOut, resetZoom, zoomIn } = - useEditorZoom(); +const { zoomPercent, canZoomOut, canZoomIn, zoomOut, resetZoom, zoomIn } = useEditorZoom(); const mode = ref<"chars" | "lines">("chars"); +const { shortcuts } = useShortcutConfig(); + +// 状态栏只在主编辑器窗口存在,由它注册缩放快捷键 +installEditorZoomShortcuts(); + +// 提示读用户在设置里配置的绑定,改完立即同步 +const zoomShortcut = (id: "zoomIn" | "zoomOut" | "resetZoom") => + shortcuts.value.find((s) => s.id === id)?.key || ""; + +const zoomOutLabel = computed( + () => `缩小编辑区(${formatKeyForDisplay(zoomShortcut("zoomOut"))})` +); +const zoomInLabel = computed(() => `放大编辑区(${formatKeyForDisplay(zoomShortcut("zoomIn"))})`); +const zoomValueLabel = computed( + () => + `编辑区缩放 ${zoomPercent.value}%,点击还原(${formatKeyForDisplay(zoomShortcut("resetZoom"))})` +); const displayText = computed(() => { const text = props.content ?? ""; @@ -98,8 +115,8 @@ window.electronAPI.on("view:toggleView", () => {