From dd03fadd4d15960c0360e0bc7e87f47e9e851eeb Mon Sep 17 00:00:00 2001 From: runningwaterpro Date: Sat, 26 Sep 2026 08:27:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BC=96=E8=BE=91=E5=8C=BA=E7=BC=A9?= =?UTF-8?q?=E6=94=BE=E6=94=AF=E6=8C=81=E5=BF=AB=E6=8D=B7=E9=94=AE=E4=B8=8E?= =?UTF-8?q?=E6=BB=9A=E8=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现在除了状态栏按钮,还可以用键盘和触控板调整当前标签的编辑内容显示比例, 放大、缩小和还原时阅读位置不会跳走。 - `Ctrl/Cmd + =` 和 `Ctrl/Cmd + +` 放大,`Ctrl/Cmd + -` 缩小,`Ctrl/Cmd + 0` 还原 - 快捷键在主窗口获得焦点时即可生效,不需要先点击编辑区 - `Ctrl/Cmd + 滚轮` 调整倍率,普通滚轮保持原有滚动;触控板一次捏合只走一个 10% 档位 - 缩放前后按比例还原滚动位置,代码块复制按钮和语言/模式选择器保持固定尺寸 - 链接提示框改挂到 body 上用视口坐标定位,不再跟随内容缩放或被滚动容器裁掉 - 移除原生菜单的"实际大小"项,避免和 `Ctrl/Cmd + 0` 抢按键 - "设为段落"快捷键从 `Mod-0` 移到 `Mod-Shift-0`,把 `Ctrl/Cmd + 0` 让给缩放还原 - 缩放快捷键由状态栏注册,主题编辑器窗口不再响应 源码模式与富文本共用同一个标签倍率,导出流程不包含缩放,倍率仍只存在于打开标签的内存中。 验证: - pnpm test:34 个测试通过 - pnpm build:通过 - Electron 37 实测:50%~300% 下正文按倍率缩放、外壳与辅助工具尺寸不变、 链接提示框定位偏差 0px、滚动位置恢复误差 <= 0.0006 Related: runningwaterpro/milkup#14 Related: runningwaterpro/milkup#15 Related: runningwaterpro/milkup#16 --- src/core/editor.ts | 32 ++-- src/core/keymap/shortcut-registry.ts | 8 +- src/core/styles/milkup.css | 3 +- src/main/menu.ts | 1 - .../components/editor/MilkupEditor.vue | 64 ++++++-- src/renderer/components/menu/StatusBar.vue | 24 ++- src/renderer/hooks/useEditorZoom.ts | 25 +++ src/renderer/utils/editorZoom.ts | 60 ++++++- test/editor-zoom.test.mjs | 151 +++++++++++++++++- 9 files changed, 318 insertions(+), 50 deletions(-) diff --git a/src/core/editor.ts b/src/core/editor.ts index e6748256..08fe29c2 100644 --- a/src/core/editor.ts +++ b/src/core/editor.ts @@ -487,6 +487,8 @@ export class MilkupEditor implements IMilkupEditor { private static readonly IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); private static readonly MOD_KEY = MilkupEditor.IS_MAC ? "⌘" : "Ctrl"; + /** 与 .milkup-link-tooltip 的 max-width 保持一致 */ + private static readonly LINK_TOOLTIP_MAX_WIDTH = 400; /** 从 DOM 元素向上查找最近的 标签 */ private findLinkElement(target: HTMLElement): HTMLAnchorElement | null { @@ -718,12 +720,12 @@ export class MilkupEditor implements IMilkupEditor { return; } - const container = this.view.dom.parentElement || this.view.dom; - if (!this.linkTooltip) { this.linkTooltip = document.createElement("div"); this.linkTooltip.className = "milkup-link-tooltip"; - container.appendChild(this.linkTooltip); + // 挂在 body 上并用 fixed 定位:编辑区可能被缩放,挂在缩放容器内 + // 既会跟着放大,也会被滚动容器裁掉 + document.body.appendChild(this.linkTooltip); } const tip = this.linkTooltip; @@ -731,24 +733,16 @@ export class MilkupEditor implements IMilkupEditor { tip.textContent = `${displayHref} ${MilkupEditor.MOD_KEY}+左击访问`; tip.style.display = "block"; + // fixed 定位使用视口坐标,缩放不影响这里的计算 const linkRect = linkEl.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - - let left = linkRect.left - containerRect.left; - const top = linkRect.bottom - containerRect.top + 4; - tip.style.top = `${top}px`; + const margin = 8; + let left = linkRect.left; + // 只有贴近右边缘时才读一次宽度做钳制,避免每次 hover 都强制同步布局 + if (left + LINK_TOOLTIP_MAX_WIDTH > window.innerWidth - margin) { + left = Math.max(margin, window.innerWidth - tip.offsetWidth - margin); + } tip.style.left = `${left}px`; - - // 下一帧修正右侧溢出 - requestAnimationFrame(() => { - if (!this.linkTooltip) return; - const tipRect = this.linkTooltip.getBoundingClientRect(); - const cr = container.getBoundingClientRect(); - if (tipRect.right > cr.right - 8) { - left = cr.right - cr.left - tipRect.width - 8; - this.linkTooltip.style.left = `${Math.max(0, left)}px`; - } - }); + tip.style.top = `${linkRect.bottom + 4}px`; this.linkTooltipCurrentLink = linkEl; } diff --git a/src/core/keymap/shortcut-registry.ts b/src/core/keymap/shortcut-registry.ts index c5965ff1..79a66534 100644 --- a/src/core/keymap/shortcut-registry.ts +++ b/src/core/keymap/shortcut-registry.ts @@ -38,7 +38,13 @@ export const DEFAULT_SHORTCUTS: ShortcutDefinition[] = [ { id: "setHeading4", label: "四级标题", category: "block", key: "Mod-4", defaultKey: "Mod-4" }, { id: "setHeading5", label: "五级标题", category: "block", key: "Mod-5", defaultKey: "Mod-5" }, { id: "setHeading6", label: "六级标题", category: "block", key: "Mod-6", defaultKey: "Mod-6" }, - { id: "setParagraph", label: "段落", category: "block", key: "Mod-0", defaultKey: "Mod-0" }, + { + id: "setParagraph", + label: "段落", + category: "block", + key: "Mod-Shift-0", + defaultKey: "Mod-Shift-0", + }, { id: "setCodeBlock", label: "代码块", diff --git a/src/core/styles/milkup.css b/src/core/styles/milkup.css index 4c3aae72..747c9d83 100644 --- a/src/core/styles/milkup.css +++ b/src/core/styles/milkup.css @@ -1622,9 +1622,10 @@ } /* ============ 链接 Tooltip ============ */ +/* 挂在 body 上并用视口坐标定位,不受编辑区缩放影响 */ .milkup-link-tooltip { display: none; - position: absolute; + position: fixed; z-index: 100; max-width: 400px; padding: 4px 8px; diff --git a/src/main/menu.ts b/src/main/menu.ts index 0e3a9362..d6cf0e03 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -66,7 +66,6 @@ export default function createMenu() { { label: "视图", submenu: [ - { label: "实际大小", accelerator: "CmdOrCtrl+0", role: "resetZoom" }, { label: "全屏", accelerator: "F11", role: "togglefullscreen" }, { label: "切换视图", diff --git a/src/renderer/components/editor/MilkupEditor.vue b/src/renderer/components/editor/MilkupEditor.vue index 5d7dc037..d38028a8 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,15 +163,36 @@ 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); + }); +} + +/** 缩放会改变可视高度,等 DOM 更新完再按原比例还原,避免阅读位置跳动 */ +function restoreScrollRatio(ratio: number) { + if (zoomScrollRafId !== null) cancelAnimationFrame(zoomScrollRafId); + nextTick(() => { + zoomScrollRafId = requestAnimationFrame(() => { + zoomScrollRafId = null; + const scrollView = scrollViewRef.value; + if (scrollView) writeScrollRatio(scrollView, ratio); + }); }); } @@ -333,10 +354,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 +381,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 +416,7 @@ onUnmounted(() => { isEditorInitializing.value = false; if (newlyLoadedTimer) clearTimeout(newlyLoadedTimer); if (outlineTimer) clearTimeout(outlineTimer); + if (zoomScrollRafId !== null) cancelAnimationFrame(zoomScrollRafId); emitter.off("sourceView:toggle", handleSourceViewToggle); emitter.off("outline:scrollTo", handleOutlineScrollTo); emitter.off("editor:reload", handleEditorReload); @@ -496,6 +512,15 @@ watch( } ); +// 缩放前后保持当前阅读位置 +watch( + () => props.tab.zoomPercent, + () => { + const scrollView = scrollViewRef.value; + if (scrollView) restoreScrollRatio(readScrollRatio(scrollView)); + } +); + // 监听 tab.readOnly 变化 watch( () => props.tab.readOnly, @@ -537,7 +562,12 @@ defineExpose({ :data-tab-id="tab.id" :data-active="isActive ? 'true' : 'false'" > -
+
@@ -570,6 +600,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..2725dc8c 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 } from "@/renderer/hooks/useShortcutConfig"; import useSourceCode from "@/renderer/hooks/useSourceCode"; const props = defineProps<{ @@ -20,6 +21,15 @@ const { zoomPercent, canZoomOut, canZoomIn, canResetZoom, zoomOut, resetZoom, zo useEditorZoom(); const mode = ref<"chars" | "lines">("chars"); +// 状态栏只在主编辑器窗口存在,由它注册缩放快捷键 +installEditorZoomShortcuts(); + +const zoomOutLabel = computed(() => `缩小编辑区(${formatKeyForDisplay("Mod-minus")})`); +const zoomInLabel = computed(() => `放大编辑区(${formatKeyForDisplay("Mod-=")})`); +const zoomValueLabel = computed( + () => `编辑区缩放 ${zoomPercent.value}%,点击还原(${formatKeyForDisplay("Mod-0")})` +); + const displayText = computed(() => { const text = props.content ?? ""; switch (mode.value) { @@ -98,8 +108,8 @@ window.electronAPI.on("view:toggleView", () => {