Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 13 additions & 19 deletions src/core/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 元素向上查找最近的 <a> 标签 */
private findLinkElement(target: HTMLElement): HTMLAnchorElement | null {
Expand Down Expand Up @@ -718,37 +720,29 @@ 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;
const displayHref = href.length > 60 ? href.slice(0, 57) + "..." : href;
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;
}
Expand Down
8 changes: 7 additions & 1 deletion src/core/keymap/shortcut-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "代码块",
Expand Down
3 changes: 2 additions & 1 deletion src/core/styles/milkup.css
Original file line number Diff line number Diff line change
Expand Up @@ -1622,9 +1622,10 @@
}

/* ============ 链接 Tooltip ============ */
/* 挂在 body 上并用视口坐标定位,不受编辑区缩放影响 */
.milkup-link-tooltip {
display: none;
position: absolute;
position: fixed;
z-index: 100;
max-width: 400px;
padding: 4px 8px;
Expand Down
1 change: 0 additions & 1 deletion src/main/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ export default function createMenu() {
{
label: "视图",
submenu: [
{ label: "实际大小", accelerator: "CmdOrCtrl+0", role: "resetZoom" },
{ label: "全屏", accelerator: "F11", role: "togglefullscreen" },
{
label: "切换视图",
Expand Down
64 changes: 50 additions & 14 deletions src/renderer/components/editor/MilkupEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ interface Props {
}

const props = defineProps<Props>();
const { contentStyleFor } = useEditorZoom();
const { contentStyleFor, handleWheel } = useEditorZoom();
const editorContentStyle = computed(() => contentStyleFor(props.tab));

const LARGE_DOCUMENT_CHAR_THRESHOLD = 200_000;
Expand Down Expand Up @@ -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);
});
});
}

Expand Down Expand Up @@ -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);
}
});
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -496,6 +512,15 @@ watch(
}
);

// 缩放前后保持当前阅读位置
watch(
() => props.tab.zoomPercent,
() => {
const scrollView = scrollViewRef.value;
if (scrollView) restoreScrollRatio(readScrollRatio(scrollView));
}
);

// 监听 tab.readOnly 变化
watch(
() => props.tab.readOnly,
Expand Down Expand Up @@ -537,7 +562,12 @@ defineExpose({
:data-tab-id="tab.id"
:data-active="isActive ? 'true' : 'false'"
>
<div ref="scrollViewRef" class="scrollView milkup" @scroll="updateScrollRatio">
<div
ref="scrollViewRef"
class="scrollView milkup"
@scroll="updateScrollRatio"
@wheel="handleWheel"
>
<div class="editor-zoom-surface" :style="editorContentStyle">
<div ref="containerRef" class="milkup-container"></div>
</div>
Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 17 additions & 7 deletions src/renderer/components/menu/StatusBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -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) {
Expand Down Expand Up @@ -98,8 +108,8 @@ window.electronAPI.on("view:toggleView", () => {
<div class="zoomControls" role="group" aria-label="编辑视图缩放">
<button
type="button"
aria-label="缩小编辑区"
title="缩小编辑区"
:aria-label="zoomOutLabel"
:title="zoomOutLabel"
:disabled="!canZoomOut"
@click="zoomOut"
>
Expand All @@ -108,17 +118,17 @@ window.electronAPI.on("view:toggleView", () => {
<button
type="button"
class="zoomValue"
:aria-label="`编辑区缩放 ${zoomPercent}%,点击还原`"
:title="`编辑区缩放 ${zoomPercent}%,点击还原`"
:aria-label="zoomValueLabel"
:title="zoomValueLabel"
:disabled="!canResetZoom"
@click="resetZoom"
>
{{ zoomPercent }}%
</button>
<button
type="button"
aria-label="放大编辑区"
title="放大编辑区"
:aria-label="zoomInLabel"
:title="zoomInLabel"
:disabled="!canZoomIn"
@click="zoomIn"
>
Expand Down
25 changes: 25 additions & 0 deletions src/renderer/hooks/useEditorZoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,34 @@ import { createEditorZoomController } from "@/renderer/utils/editorZoom";
import useTab from "./useTab";

let controller: ReturnType<typeof createEditorZoomController> | undefined;
let shortcutsInstalled = false;

export default function useEditorZoom() {
if (!controller) controller = createEditorZoomController(useTab().currentTab);

return controller;
}

/**
* 注册编辑区缩放快捷键。只应由状态栏调用:
* 状态栏只存在于主编辑器窗口,主题编辑器窗口渲染的是
* ThemeEditor 预览,不应该响应缩放快捷键。
*/
export function installEditorZoomShortcuts() {
if (shortcutsInstalled) return;

const zoom = useEditorZoom();
const isMac = window.electronAPI.platform === "darwin";
document.addEventListener(
"keydown",
(event) => {
if (event.target instanceof Element && event.target.closest(".shortcut-page")) return;
if (!zoom.handleKeydown(event, isMac)) return;

event.preventDefault();
event.stopPropagation();
},
true
);
shortcutsInstalled = true;
}
Loading