From 413564efcf96bf9a7b7414499d5e82ac8947b11e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E7=BF=80?= <1020327414@qq.com> Date: Tue, 27 Oct 2026 16:52:25 +0800 Subject: [PATCH] feat(editor): Increase / Decrease Font Size, followed by the Markdown preview (#81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- src/editor/core/actions.ts | 18 ++- src/editor/core/cm/prefs.ts | 15 +- .../viewers/markdown/Markdown.module.css | 4 +- .../viewers/markdown/MarkdownEditor.tsx | 3 + src/features/viewers/markdown/live.ts | 9 +- src/features/viewers/markdown/zoom.test.tsx | 130 ++++++++++++++++++ src/platform/keymaps/ideaParity.test.ts | 2 + src/platform/keymaps/intellij-macos.ts | 2 + src/platform/keymaps/intellij-windows.ts | 2 + src/platform/keymaps/vscode-macos.ts | 2 + src/ui/selectableSurfaces.test.tsx | 4 +- 11 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 src/features/viewers/markdown/zoom.test.tsx diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index e2c9727ae..be34d9d9f 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -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", diff --git a/src/editor/core/cm/prefs.ts b/src/editor/core/cm/prefs.ts index bf9a93444..95c5cf61d 100644 --- a/src/editor/core/cm/prefs.ts +++ b/src/editor/core/cm/prefs.ts @@ -343,8 +343,9 @@ export const setFontZoom = StateEffect.define(); export const fontZoom = StateField.define({ 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 = {}; @@ -355,13 +356,19 @@ export const fontZoom = StateField.define({ ), }); +/** 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; }, }); diff --git a/src/features/viewers/markdown/Markdown.module.css b/src/features/viewers/markdown/Markdown.module.css index 57f2e6796..817ece1ea 100644 --- a/src/features/viewers/markdown/Markdown.module.css +++ b/src/features/viewers/markdown/Markdown.module.css @@ -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; diff --git a/src/features/viewers/markdown/MarkdownEditor.tsx b/src/features/viewers/markdown/MarkdownEditor.tsx index a8c39c2cd..f218bacc0 100644 --- a/src/features/viewers/markdown/MarkdownEditor.tsx +++ b/src/features/viewers/markdown/MarkdownEditor.tsx @@ -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 }) {
>(() => ({})); @@ -22,7 +25,7 @@ const TEXT_DELAY = 120; function publish(path: string, patch: Partial) { 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); diff --git a/src/features/viewers/markdown/zoom.test.tsx b/src/features/viewers/markdown/zoom.test.tsx new file mode 100644 index 000000000..75ae2afa6 --- /dev/null +++ b/src/features/viewers/markdown/zoom.test.tsx @@ -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 | null = null; +vi.mock("@/editor/core/controller", async (importOriginal) => ({ + ...(await importOriginal()), + 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( + +
+ , + ); + 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[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([]); + } + }); +}); diff --git a/src/platform/keymaps/ideaParity.test.ts b/src/platform/keymaps/ideaParity.test.ts index 8376db456..51f9600f6 100644 --- a/src/platform/keymaps/ideaParity.test.ts +++ b/src/platform/keymaps/ideaParity.test.ts @@ -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"]], diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index 695f58dfd..9290b8650 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -271,6 +271,8 @@ export const intellijMacos: Record = { "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"], diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index bafc2f083..f2ca0ee3d 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -268,6 +268,8 @@ export const intellijWindows: Record = { "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"], diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index b6d60fc6a..28a3d7dfb 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -266,6 +266,8 @@ export const vscodeMacos: Record = { "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": [], diff --git a/src/ui/selectableSurfaces.test.tsx b/src/ui/selectableSurfaces.test.tsx index 4b9658d94..b5375570f 100644 --- a/src/ui/selectableSurfaces.test.tsx +++ b/src/ui/selectableSurfaces.test.tsx @@ -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(