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