feat(editor): Increase / Decrease Font Size, followed by the Markdown preview (#81)

Adds Increase Font Size / Decrease Font Size (⌥⇧. / ⌥⇧, in the IntelliJ keymaps) for the active editor, sharing the ±1px step and limits with ⌘/Ctrl + mouse wheel. The Markdown preview, including code blocks, follows its editor's zoom, and Reset Font Size in All Editors resets it too.

Editor zoom had no effect once a font size was configured in settings: the configured size was applied with higher precedence than the zoom. The zoom now wins.

Co-authored-by: zzxzz12345 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
胡翀таzzxzz12345 authored and GitHub committed 2026-10-27 16:52:25 +08:00
1 джерело f675dd2730
коміт 413564efcf
11 files changed
+181 -10

No files matched your search

+17 -1
Переглянути файл
@@ -37,7 +37,7 @@ import { qualifiedReference } from "./copyReference";
import { symbolChain } from "./view/outline";
import { recordClipboard } from "./clipboardHistory";
import { useDocMeta, useOutline } from "./model/document";
import { fontZoom, setFontZoom } from "./cm/prefs";
import { fontZoom, setFontZoom, stepFontZoom } from "./cm/prefs";
import { useEditorRegistry } from "@/editor/api";
import { useProject } from "@/platform/project";
import { call, commands } from "@/platform/ipc";
@@ -471,6 +471,22 @@ export function registerEditorActions(): () => void {
if (ed.view.state.field(fontZoom, false)) ed.view.dispatch({ effects: setFontZoom.of(0) });
},
},
{
id: "editor.increaseFontSize",
title: "Increase Font Size",
category: "View",
keywords: ["zoom", "font size"],
when: EDITOR_WHEN,
run: withView((view) => stepFontZoom(view, 1)),
},
{
id: "editor.decreaseFontSize",
title: "Decrease Font Size",
category: "View",
keywords: ["zoom", "font size"],
when: EDITOR_WHEN,
run: withView((view) => stepFontZoom(view, -1)),
},
{
id: "file.toggleReadOnly",
title: "Toggle Read-only Attribute",
+11 -4
Переглянути файл
@@ -343,8 +343,9 @@ export const setFontZoom = StateEffect.define<number>();
export const fontZoom = StateField.define<number>({
create: () => 0,
update: (v, tr) => tr.effects.reduce((acc, e) => (e.is(setFontZoom) ? e.value : acc), v),
// Highest precedence: its style comes after the configured font size, which it scales.
provide: (f) =>
Prec.lowest(
Prec.highest(
EditorView.editorAttributes.compute([f], (s) => {
const z = s.field(f);
const attrs: Record<string, string> = {};
@@ -355,13 +356,19 @@ export const fontZoom = StateField.define<number>({
),
});
/** Steps an editor's font zoom by `delta` px, kept within -6..36. */
export function stepFontZoom(view: EditorView, delta: number): void {
const z = view.state.field(fontZoom, false);
if (z === undefined) return;
const next = Math.max(-6, Math.min(36, z + delta));
if (next !== z) view.dispatch({ effects: setFontZoom.of(next) });
}
const wheelZoom = EditorView.domEventHandlers({
wheel(e, view) {
if (!e.metaKey && !e.ctrlKey) return false;
e.preventDefault();
const z = view.state.field(fontZoom, false) ?? 0;
const next = Math.max(-6, Math.min(36, z + (e.deltaY < 0 ? 1 : -1)));
if (next !== z) view.dispatch({ effects: setFontZoom.of(next) });
stepFontZoom(view, e.deltaY < 0 ? 1 : -1);
return true;
},
});
+2 -2
Переглянути файл
@@ -47,7 +47,7 @@
background: var(--bg-editor, var(--bg-app));
color: var(--fg);
font-family: var(--font-ui);
font-size: 14px;
font-size: calc(14px + var(--markdown-font-zoom, 0px));
line-height: 1.6;
overflow-wrap: break-word;
}
@@ -112,7 +112,7 @@
.preview :global(pre code) {
padding: 0;
background: none;
font-size: var(--fs-code);
font-size: calc(var(--fs-code) + var(--markdown-font-zoom, 0px));
}
.preview :global(blockquote) {
padding: 0 1em;
+3
Переглянути файл
@@ -11,6 +11,7 @@ import {
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type MouseEvent,
type ReactNode,
} from "react";
@@ -80,6 +81,7 @@ export function MarkdownEditor({ path, children }: { path: string; tabId: string
function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
const text = useLiveMarkdown((s) => s[path]?.text);
const fontZoom = useLiveMarkdown((s) => s[path]?.fontZoom ?? 0);
const topLine = useLiveMarkdown((s) => s[path]?.topLine ?? 0);
const scrollSeq = useLiveMarkdown((s) => s[path]?.scrollSeq ?? 0);
const deferred = useDeferredValue(text ?? "");
@@ -169,6 +171,7 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
<div
ref={ref}
className={clsx(styles.preview, selectableText)}
style={{ "--markdown-font-zoom": `${fontZoom}px` } as CSSProperties}
tabIndex={-1}
role="document"
aria-label="Markdown preview"
+7 -2
Переглянути файл
@@ -6,6 +6,7 @@
import { ViewPlugin, type EditorView, type ViewUpdate } from "@codemirror/view";
import { create } from "zustand";
import { registerEditorContribution } from "@/editor/api";
import { fontZoom } from "@/editor/core/cm/prefs";
import { isMarkdownPath } from "../fileKinds";
export interface LiveMarkdown {
@@ -14,6 +15,8 @@ export interface LiveMarkdown {
topLine: number;
/** Bumped when the editor scrolls, so equal lines still resync. */
scrollSeq: number;
/** The editor's font zoom in px; the preview scales its text by the same amount. */
fontZoom: number;
}
export const useLiveMarkdown = create<Record<string, LiveMarkdown | undefined>>(() => ({}));
@@ -22,7 +25,7 @@ const TEXT_DELAY = 120;
function publish(path: string, patch: Partial<LiveMarkdown>) {
useLiveMarkdown.setState((s) => {
const prev = s[path] ?? { text: "", topLine: 0, scrollSeq: 0 };
const prev = s[path] ?? { text: "", topLine: 0, scrollSeq: 0, fontZoom: 0 };
return { [path]: { ...prev, ...patch } };
});
}
@@ -47,11 +50,13 @@ function livePlugin(path: string) {
};
constructor(private view: EditorView) {
publish(path, { text: view.state.doc.toString() });
publish(path, { text: view.state.doc.toString(), fontZoom: view.state.field(fontZoom, false) ?? 0 });
view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true });
}
update(u: ViewUpdate) {
const zoom = u.state.field(fontZoom, false) ?? 0;
if (zoom !== (u.startState.field(fontZoom, false) ?? 0)) publish(path, { fontZoom: zoom });
if (!u.docChanged) return;
clearTimeout(this.timer);
this.timer = setTimeout(() => publish(path, { text: this.view.state.doc.toString() }), TEXT_DELAY);
+130
Переглянути файл
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { _addEditor, _removeEditor, editorContributions, type EditorDocContext } from "@/editor/api";
import { fontZoom, setFontZoom } from "@/editor/core/cm/prefs";
import { fontExtension } from "@/editor/core/cm/setup";
import type { EditorSlot } from "@/editor/core/model/slot";
import { executeAction } from "@/platform/actions";
import { useContextKeys } from "@/platform/context";
import { intellijMacos } from "@/platform/keymaps/intellij-macos";
import { intellijWindows } from "@/platform/keymaps/intellij-windows";
import { vscodeMacos } from "@/platform/keymaps/vscode-macos";
let activeSlot: Partial<EditorSlot> | null = null;
vi.mock("@/editor/core/controller", async (importOriginal) => ({
...(await importOriginal<object>()),
editorController: () => ({
get activeSlot() {
return activeSlot;
},
}),
}));
const { registerEditorActions } = await import("@/editor/core/actions");
const { registerMarkdownLiveSource, useLiveMarkdown } = await import("./live");
const { MarkdownEditor } = await import("./MarkdownEditor");
const { useMarkdownModes } = await import("./modes");
const mdCtx: EditorDocContext = { doc: 1, path: "/p/README.md", language: "markdown", uiMode: null, readOnly: false };
const tsCtx: EditorDocContext = { doc: 2, path: "/p/a.ts", language: "typescript", uiMode: null, readOnly: false };
describe("editor font size actions", () => {
const disposers: Array<() => void> = [];
const views: EditorView[] = [];
const open = (ctx: EditorDocContext) => {
const live = editorContributions().find((c) => c.id === "viewers.markdownLive")!;
const extensions = [fontZoom, live.extensions(ctx)];
const view = new EditorView({ state: EditorState.create({ doc: "# Title", extensions }) });
const handle = { view, ctx };
_addEditor(handle);
disposers.push(() => _removeEditor(handle));
views.push(view);
return view;
};
const activate = (view: EditorView) => (activeSlot = { view, disposed: false });
const zoom = (view: EditorView) => view.state.field(fontZoom);
beforeEach(() => {
disposers.push(registerEditorActions(), registerMarkdownLiveSource());
useContextKeys.setState({ projectOpen: true, textEditorActive: true, editorFocus: true, popupOpen: true });
});
afterEach(() => {
cleanup();
for (const view of views.splice(0)) view.destroy();
for (const dispose of disposers.splice(0).reverse()) dispose();
activeSlot = null;
useLiveMarkdown.setState({}, true);
useContextKeys.setState({}, true);
});
it("zooms only the active editor, clamped to -6..36", async () => {
const md = open(mdCtx);
const ts = open(tsCtx);
activate(md);
await executeAction("editor.increaseFontSize");
await executeAction("editor.increaseFontSize");
expect(zoom(md)).toBe(2);
expect(zoom(ts)).toBe(0);
activate(ts);
await executeAction("editor.decreaseFontSize");
expect(zoom(ts)).toBe(-1);
expect(zoom(md)).toBe(2);
ts.dispatch({ effects: setFontZoom.of(36) });
await executeAction("editor.increaseFontSize");
expect(zoom(ts)).toBe(36);
ts.dispatch({ effects: setFontZoom.of(-6) });
await executeAction("editor.decreaseFontSize");
expect(zoom(ts)).toBe(-6);
});
it("scales the Markdown preview with its editor and resets with it", async () => {
const md = open(mdCtx);
activate(md);
act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, [mdCtx.path]: "split" } })));
render(
<MarkdownEditor path={mdCtx.path} tabId="t">
<div />
</MarkdownEditor>,
);
const preview = screen.getByRole("document", { name: "Markdown preview" });
expect(preview.style.getPropertyValue("--markdown-font-zoom")).toBe("0px");
await act(() => executeAction("editor.increaseFontSize"));
await act(() => executeAction("editor.increaseFontSize"));
expect(preview.style.getPropertyValue("--markdown-font-zoom")).toBe("2px");
await act(() => executeAction("editor.resetFontSize"));
expect(zoom(md)).toBe(0);
expect(preview.style.getPropertyValue("--markdown-font-zoom")).toBe("0px");
});
it("scales a configured editor font size", () => {
const settings = { fontSize: 13 } as Parameters<typeof fontExtension>[0];
const view = new EditorView({ state: EditorState.create({ extensions: [fontExtension(settings), fontZoom] }) });
views.push(view);
view.dispatch({ effects: setFontZoom.of(2) });
expect(view.dom.style.getPropertyValue("--openidea-editor-base-size")).toBe("13px");
expect(view.dom.style.getPropertyValue("--openidea-editor-font-size")).toContain("+ 2px");
});
it("leaves ⌘= / ⌘- to fold and unfold", () => {
for (const km of [intellijMacos, intellijWindows]) {
expect(km["editor.increaseFontSize"]).toEqual(["Alt+Shift+."]);
expect(km["editor.decreaseFontSize"]).toEqual(["Alt+Shift+,"]);
}
expect(intellijMacos["code.fold"]).toEqual(["Meta+-"]);
expect(intellijMacos["code.unfold"]).toEqual(["Meta+="]);
expect(intellijWindows["code.fold"]).toEqual(["Ctrl+-"]);
expect(intellijWindows["code.unfold"]).toEqual(["Ctrl+="]);
for (const km of [intellijMacos, intellijWindows, vscodeMacos]) {
const keys = [...(km["editor.increaseFontSize"] ?? []), ...(km["editor.decreaseFontSize"] ?? [])];
expect(keys.filter((k) => /^(Meta|Ctrl)\+(Shift\+)?[=-]$/.test(k))).toEqual([]);
expect(Object.keys(km).filter((id) => id.startsWith("markdown.zoom"))).toEqual([]);
}
});
});
+2
Переглянути файл
@@ -12,6 +12,8 @@ describe("IDEA keymap parity", () => {
["view.zoomIn", ["Ctrl+Alt+="], ["Alt+Shift+="]],
["view.zoomOut", ["Ctrl+Alt+-"], ["Alt+Shift+-"]],
["view.resetZoom", ["Ctrl+Alt+0"], ["Alt+Shift+0"]],
["editor.increaseFontSize", ["Alt+Shift+."], ["Alt+Shift+."]],
["editor.decreaseFontSize", ["Alt+Shift+,"], ["Alt+Shift+,"]],
["vcs.changelist.move", ["Meta+Shift+M"], ["Alt+Shift+M"]],
["vcs.changelist.edit", ["F2", "Shift+F6"], ["F2", "Shift+F6"]],
["code.quickDocumentation", ["F1", "Ctrl+J"], ["Ctrl+Q"]],
+2
Переглянути файл
@@ -271,6 +271,8 @@ export const intellijMacos: Record<string, string[]> = {
"view.zoomIn": ["Ctrl+Alt+="],
"view.zoomOut": ["Ctrl+Alt+-"],
"view.resetZoom": ["Ctrl+Alt+0"],
"editor.increaseFontSize": ["Alt+Shift+."],
"editor.decreaseFontSize": ["Alt+Shift+,"],
// Diff viewer (after Debug: F7 / ⇧F7 / F4 there win while a diff is focused)
"diff.nextDifference": ["F7"],
"diff.prevDifference": ["Shift+F7"],
+2
Переглянути файл
@@ -268,6 +268,8 @@ export const intellijWindows: Record<string, string[]> = {
"view.zoomIn": ["Alt+Shift+="],
"view.zoomOut": ["Alt+Shift+-"],
"view.resetZoom": ["Alt+Shift+0"],
"editor.increaseFontSize": ["Alt+Shift+."],
"editor.decreaseFontSize": ["Alt+Shift+,"],
// Diff viewer (after Debug: F7 / ⇧F7 / F4 there win while a diff is focused)
"diff.nextDifference": ["F7"],
"diff.prevDifference": ["Shift+F7"],
+2
Переглянути файл
@@ -266,6 +266,8 @@ export const vscodeMacos: Record<string, string[]> = {
"view.zoomIn": ["Meta+="],
"view.zoomOut": ["Meta+-"],
"view.resetZoom": [],
"editor.increaseFontSize": [],
"editor.decreaseFontSize": [],
// Editing extras, image viewer (keyWhen: image focused) and Markdown editor layouts
"code.cyclicExpandWord": [],
"code.cyclicExpandWordBackward": [],
+3 -1
Переглянути файл
@@ -21,7 +21,9 @@ afterEach(() => {
describe("selectable read-only surfaces", () => {
it("Markdown preview (the reported case)", () => {
act(() =>
useLiveMarkdown.setState({ "/p/README.md": { text: "# Title\n\nSome *text*", topLine: 0, scrollSeq: 0 } }),
useLiveMarkdown.setState({
"/p/README.md": { text: "# Title\n\nSome *text*", topLine: 0, scrollSeq: 0, fontZoom: 0 },
}),
);
act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, "/p/README.md": "preview" } })));
render(