Skip to content
Merged
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
13 changes: 12 additions & 1 deletion src/renderer/components/editor/MilkupEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { undo, redo } from "prosemirror-history";
import { uploadImage } from "@/renderer/services/api";
import { AIService } from "@/renderer/services/ai";
import { useAIConfig } from "@/renderer/hooks/useAIConfig";
import useEditorZoom from "@/renderer/hooks/useEditorZoom";
import { useConfig } from "@/renderer/hooks/useConfig";
import useUiLoading from "@/renderer/hooks/useUiLoading";
import LoadingIcon from "@/renderer/components/ui/LoadingIcon.vue";
Expand All @@ -30,6 +31,8 @@ interface Props {
}

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

const LARGE_DOCUMENT_CHAR_THRESHOLD = 200_000;
const LARGE_DOCUMENT_LINE_THRESHOLD = 4_000;
Expand Down Expand Up @@ -531,7 +534,9 @@ defineExpose({
:data-active="isActive ? 'true' : 'false'"
>
<div ref="scrollViewRef" class="scrollView milkup" @scroll="updateScrollRatio">
<div ref="containerRef" class="milkup-container"></div>
<div class="editor-zoom-surface" :style="editorContentStyle">
<div ref="containerRef" class="milkup-container"></div>
</div>
</div>
<div v-if="isEditorInitializing" class="editor-loading-overlay">
<div class="editor-loading-card">
Expand All @@ -557,6 +562,12 @@ defineExpose({
background: var(--background-color-1);
}

.editor-zoom-surface {
min-height: 100%;
display: flex;
flex-direction: column;
}

.milkup-container {
min-height: 100%;
display: flex;
Expand Down
78 changes: 77 additions & 1 deletion src/renderer/components/menu/StatusBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
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 useSourceCode from "@/renderer/hooks/useSourceCode";

const props = defineProps<{
Expand All @@ -15,6 +16,8 @@ const emit = defineEmits<{
}>();

const { isShowSource, toggleSourceCode } = useSourceCode();
const { zoomPercent, canZoomOut, canZoomIn, canResetZoom, zoomOut, resetZoom, zoomIn } =
useEditorZoom();
const mode = ref<"chars" | "lines">("chars");

const displayText = computed(() => {
Expand Down Expand Up @@ -90,7 +93,39 @@ window.electronAPI.on("view:toggleView", () => {
</div>
</div>

<span class="statusBarText" @click="cycleMode">{{ displayText }}</span>
<div class="right-section">
<span class="statusBarText" @click="cycleMode">{{ displayText }}</span>
<div class="zoomControls" role="group" aria-label="编辑视图缩放">
<button
type="button"
aria-label="缩小编辑区"
title="缩小编辑区"
:disabled="!canZoomOut"
@click="zoomOut"
>
−
</button>
<button
type="button"
class="zoomValue"
:aria-label="`编辑区缩放 ${zoomPercent}%,点击还原`"
:title="`编辑区缩放 ${zoomPercent}%,点击还原`"
:disabled="!canResetZoom"
@click="resetZoom"
>
{{ zoomPercent }}%
</button>
<button
type="button"
aria-label="放大编辑区"
title="放大编辑区"
:disabled="!canZoomIn"
@click="zoomIn"
>
+
</button>
</div>
</div>
</div>
</template>

Expand Down Expand Up @@ -122,6 +157,47 @@ window.electronAPI.on("view:toggleView", () => {
}
}

.right-section {
display: flex;
align-items: center;
gap: 8px;
}

.zoomControls {
display: inline-flex;
align-items: center;
gap: 2px;

button {
min-width: 24px;
height: 22px;
padding: 0 6px;
border: 0;
border-radius: 4px;
background: transparent;
color: inherit;
font: inherit;
line-height: 1;
cursor: pointer;

&:hover:not(:disabled) {
background: var(--hover-color);
}

&:disabled {
opacity: 0.4;
cursor: default;
}
}

.zoomValue {
min-width: 48px;
color: var(--text-color-3);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
}

.statusBarText {
font-size: 12px;
margin: 2px 0;
Expand Down
10 changes: 10 additions & 0 deletions src/renderer/hooks/useEditorZoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createEditorZoomController } from "@/renderer/utils/editorZoom";
import useTab from "./useTab";

let controller: ReturnType<typeof createEditorZoomController> | undefined;

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

return controller;
}
59 changes: 59 additions & 0 deletions src/renderer/utils/editorZoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Ref } from "vue";
import type { Tab } from "@/types/tab";
import { computed } from "vue";

export const DEFAULT_ZOOM_PERCENT = 100;
export const MIN_ZOOM_PERCENT = 50;
export const MAX_ZOOM_PERCENT = 300;
export const ZOOM_STEP_PERCENT = 10;

function getZoomPercent(tab: Tab | null | undefined): number {
return tab?.zoomPercent ?? DEFAULT_ZOOM_PERCENT;
}

function getContentStyle(tab: Tab | null | undefined): { zoom: number } {
return { zoom: getZoomPercent(tab) / 100 };
}

export function createEditorZoomController(activeTab: Readonly<Ref<Tab | null>>) {
const hasActiveTab = computed(() => activeTab.value !== null);
const zoomPercent = computed(() => getZoomPercent(activeTab.value));
const contentStyle = computed(() => getContentStyle(activeTab.value));
const canZoomOut = computed(() => hasActiveTab.value && zoomPercent.value > MIN_ZOOM_PERCENT);
const canZoomIn = computed(() => hasActiveTab.value && zoomPercent.value < MAX_ZOOM_PERCENT);
const canResetZoom = computed(
() => hasActiveTab.value && zoomPercent.value !== DEFAULT_ZOOM_PERCENT
);

function setZoomPercent(value: number) {
const tab = activeTab.value;
if (!tab) return;

tab.zoomPercent = Math.min(MAX_ZOOM_PERCENT, Math.max(MIN_ZOOM_PERCENT, value));
}

function zoomIn() {
setZoomPercent(zoomPercent.value + ZOOM_STEP_PERCENT);
}

function zoomOut() {
setZoomPercent(zoomPercent.value - ZOOM_STEP_PERCENT);
}

function resetZoom() {
setZoomPercent(DEFAULT_ZOOM_PERCENT);
}

return {
hasActiveTab,
zoomPercent,
contentStyle,
contentStyleFor: getContentStyle,
canZoomOut,
canZoomIn,
canResetZoom,
zoomIn,
zoomOut,
resetZoom,
};
}
1 change: 1 addition & 0 deletions src/types/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface Tab {
content: string;
originalContent: string;
isModified: boolean;
zoomPercent?: number;
scrollRatio?: number;
readOnly: boolean;
milkdownCursorOffset?: number | null;
Expand Down
91 changes: 91 additions & 0 deletions test/editor-zoom.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ref } from "vue";
import { createEditorZoomController } from "../src/renderer/utils/editorZoom.ts";

function createTab(overrides = {}) {
return {
id: "tab-1",
name: "Note",
filePath: null,
content: "# Note",
originalContent: "# Note",
isModified: false,
readOnly: false,
zoomPercent: 100,
...overrides,
};
}

test("editor zoom starts at the 100% baseline", () => {
const activeTab = ref(createTab());
const controller = createEditorZoomController(activeTab);

assert.equal(controller.zoomPercent.value, 100);
assert.deepEqual(controller.contentStyle.value, { zoom: 1 });
});

test("editor zoom changes in ten-percent steps and resets to 100%", () => {
const activeTab = ref(createTab());
const controller = createEditorZoomController(activeTab);

controller.zoomIn();
assert.equal(controller.zoomPercent.value, 110);
assert.deepEqual(controller.contentStyle.value, { zoom: 1.1 });

controller.zoomOut();
controller.zoomOut();
assert.equal(controller.zoomPercent.value, 90);

controller.resetZoom();
assert.equal(controller.zoomPercent.value, 100);
});

test("editor zoom clamps to 50–300% and exposes action availability", () => {
const activeTab = ref(createTab());
const controller = createEditorZoomController(activeTab);

for (let index = 0; index < 10; index += 1) controller.zoomOut();
assert.equal(controller.zoomPercent.value, 50);
assert.equal(controller.canZoomOut.value, false);
assert.equal(controller.canZoomIn.value, true);

for (let index = 0; index < 30; index += 1) controller.zoomIn();
assert.equal(controller.zoomPercent.value, 300);
assert.equal(controller.canZoomIn.value, false);
assert.equal(controller.canZoomOut.value, true);
});

test("editor zoom stays isolated per tab without changing document state", () => {
const firstTab = createTab({ id: "tab-1", filePath: "D:/notes/note.md" });
const secondTab = createTab({
id: "tab-2",
name: "Other",
content: "Other",
originalContent: "Other",
});
const activeTab = ref(firstTab);
const controller = createEditorZoomController(activeTab);
const firstTabBeforeZoom = { ...firstTab };

controller.zoomIn();
assert.equal(firstTab.zoomPercent, 110);
assert.equal(secondTab.zoomPercent, 100);
assert.deepEqual(
{ ...firstTab, zoomPercent: firstTabBeforeZoom.zoomPercent },
firstTabBeforeZoom
);
assert.deepEqual(controller.contentStyleFor(firstTab), { zoom: 1.1 });

activeTab.value = secondTab;
assert.equal(controller.zoomPercent.value, 100);

activeTab.value = firstTab;
assert.equal(controller.zoomPercent.value, 110);

activeTab.value = null;
controller.zoomIn();
assert.equal(controller.hasActiveTab.value, false);
assert.equal(controller.canZoomIn.value, false);
assert.equal(firstTab.zoomPercent, 110);
});
Loading