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
25 changes: 25 additions & 0 deletions src/core/keymap/shortcut-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
];

/** 分类中文名映射 */
Expand Down
5 changes: 4 additions & 1 deletion src/core/keymap/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ export type ShortcutActionId =
| "switchNextTab"
| "switchTabByNumber"
| "undo"
| "redo";
| "redo"
| "zoomIn"
| "zoomOut"
| "resetZoom";

/** 快捷键分类 */
export type ShortcutCategory = "inline" | "block" | "insert" | "editor" | "app";
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
121 changes: 107 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,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<typeof captureZoomAnchor>) {
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<typeof captureZoomAnchor>;
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 "";
Expand Down Expand Up @@ -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);
}
});
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -537,7 +619,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 +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 {
Expand Down
36 changes: 26 additions & 10 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, useShortcutConfig } from "@/renderer/hooks/useShortcutConfig";
import useSourceCode from "@/renderer/hooks/useSourceCode";

const props = defineProps<{
Expand All @@ -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 ?? "";
Expand Down Expand Up @@ -98,8 +115,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 +125,16 @@ window.electronAPI.on("view:toggleView", () => {
<button
type="button"
class="zoomValue"
:aria-label="`编辑区缩放 ${zoomPercent}%,点击还原`"
:title="`编辑区缩放 ${zoomPercent}%,点击还原`"
:disabled="!canResetZoom"
:aria-label="zoomValueLabel"
:title="zoomValueLabel"
@click="resetZoom"
>
{{ zoomPercent }}%
</button>
<button
type="button"
aria-label="放大编辑区"
title="放大编辑区"
:aria-label="zoomInLabel"
:title="zoomInLabel"
:disabled="!canZoomIn"
@click="zoomIn"
>
Expand Down
Loading