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", () => {