diff --git a/src/cm/mainEditorExtensions.ts b/src/cm/mainEditorExtensions.ts index f825898d4..aab943e0d 100644 --- a/src/cm/mainEditorExtensions.ts +++ b/src/cm/mainEditorExtensions.ts @@ -1,6 +1,7 @@ import type { Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import searchMatchHighlighter from "./searchMatchHighlighter"; +import pinchZoom from "./pinchZoom"; interface MainEditorExtensionOptions { emmetExtensions?: Extension[]; @@ -47,6 +48,7 @@ export function createMainEditorExtensions( pushExtension(extensions, options.commandKeymapExtension); pushExtension(extensions, options.themeExtension); extensions.push(fixedHeightTheme); + extensions.push(pinchZoom()); pushExtension(extensions, options.pointerCursorVisibilityExtension); pushExtension(extensions, options.shiftClickSelectionExtension); pushExtension(extensions, options.multiCursorSelectionExtension); diff --git a/src/cm/pinchZoom.ts b/src/cm/pinchZoom.ts new file mode 100644 index 000000000..0ac18d0a2 --- /dev/null +++ b/src/cm/pinchZoom.ts @@ -0,0 +1,203 @@ +import { ViewPlugin } from "@codemirror/view"; +import settings from "lib/settings"; + +const ZOOM_THROTTLE_MS = 50; +const MIN_FONT_SIZE = 6; +const MAX_FONT_SIZE = 72; +const DEFAULT_FONT_SIZE = 12; + +interface PinchPoint { + clientX: number; + clientY: number; +} + +export function clampEditorFontSize(px: number): number { + if (!Number.isFinite(px)) return DEFAULT_FONT_SIZE; + return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(px))); +} + +/** + * Convert a pinch distance ratio into an absolute font size. + * @param startPx font size in pixels when the gesture started + * @param startDistance distance between the two touches at the start + * @param distance current distance between the two touches + */ +export function computePinchFontSize( + startPx: number, + startDistance: number, + distance: number, +): number { + if (!Number.isFinite(startPx) || startPx <= 0) { + startPx = DEFAULT_FONT_SIZE; + } + + if ( + !Number.isFinite(startDistance) || + startDistance <= 0 || + !Number.isFinite(distance) || + distance <= 0 + ) { + return clampEditorFontSize(startPx); + } + + return clampEditorFontSize((startPx * distance) / startDistance); +} + +function touchDistance(a: PinchPoint, b: PinchPoint): number { + return Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); +} + +/** + * Read the font size the editor is actually displaying, in pixels. + * + * The font theme only sets font-size on the editor root, and settings.json may + * use any unit accepted by config.FONT_SIZE (px|rem|em|pt|mm|pc|in), so the computed + * style of the editor root is the only reliable base for the pinch + * ratio. The saved setting is used as a fallback only when no computed style is + * available (e.g. in tests). + */ +function readFontSizePx(view: { dom: HTMLElement }): number { + const computed = Number.parseFloat( + getComputedStyle(view.dom).fontSize, + ); + if (Number.isFinite(computed) && computed > 0) return computed; + + const match = String(settings?.value?.fontSize || `${DEFAULT_FONT_SIZE}px`) + .trim() + .match(/^(\d+(?:\.\d+)?)px$/i); + + if (match) { + const value = Number.parseFloat(match[1]); + if (Number.isFinite(value) && value > 0) return value; + } + + return DEFAULT_FONT_SIZE; +} + +export default function pinchZoom() { + return ViewPlugin.define((view) => { + const gesture = { + pinching: false, + moved: false, + startDistance: 0, + startPx: DEFAULT_FONT_SIZE, + lastPx: DEFAULT_FONT_SIZE, + pendingDistance: 0, + lastUpdate: 0, + originalInlineFontSize: "", + }; + + function applyFontSize(px: number) { + px = clampEditorFontSize(px); + if (px === gesture.lastPx) return; + + gesture.lastPx = px; + + // Preview directly on this editor. settings.update() only notifies + // update:fontSize listeners while the value differs from the last SAVED + // value, so writing settings mid-gesture can never undo a preview once + // the pinch returns to its starting size, and would leave the font + // theme stale as soon as the inline preview is removed. + view.dom.style.fontSize = `${px}px`; + } + + function persistFontSize() { + if (gesture.lastPx !== gesture.startPx) { + // The value differs from the saved one, so this fires update:fontSize + // (rebuilding the font theme in every pane) and writes settings.json + // exactly once per gesture, before the inline preview is dropped below. + settings.value.fontSize = `${gesture.lastPx}px`; + settings.update(false); + } + + // Let the settings-driven font theme control the editor after the gesture. + view.dom.style.fontSize = gesture.originalInlineFontSize; + } + + function onTouchStart(event: TouchEvent) { + if (gesture.pinching || event.touches.length < 2) return; + + event.preventDefault(); + + gesture.pinching = true; + gesture.moved = false; + gesture.startDistance = touchDistance( + event.touches[0], + event.touches[1], + ); + gesture.startPx = readFontSizePx(view); + gesture.lastPx = gesture.startPx; + gesture.pendingDistance = gesture.startDistance; + gesture.lastUpdate = 0; + gesture.originalInlineFontSize = view.dom.style.fontSize; + } + + function onTouchMove(event: TouchEvent) { + if (!gesture.pinching || event.touches.length < 2) return; + + event.preventDefault(); + + // The gesture received a real movement; remember the latest distance + // even when the preview itself is throttled. + gesture.moved = true; + gesture.pendingDistance = touchDistance( + event.touches[0], + event.touches[1], + ); + + const now = Date.now(); + if (now - gesture.lastUpdate < ZOOM_THROTTLE_MS) return; + + gesture.lastUpdate = now; + + applyFontSize( + computePinchFontSize( + gesture.startPx, + gesture.startDistance, + gesture.pendingDistance, + ), + ); + } + + function endPinch(event: TouchEvent) { + if (!gesture.pinching || event.touches.length >= 2) return; + + // Only flush the latest movement when the gesture actually moved: + // a two-finger tap must not round or clamp the saved size + // (9.5px -> 10px, 99px -> 72px). + if (gesture.moved) { + applyFontSize( + computePinchFontSize( + gesture.startPx, + gesture.startDistance, + gesture.pendingDistance, + ), + ); + } + + gesture.pinching = false; + persistFontSize(); + } + + const { dom } = view; + + dom.addEventListener("touchstart", onTouchStart, { passive: false }); + dom.addEventListener("touchmove", onTouchMove, { passive: false }); + dom.addEventListener("touchend", endPinch); + dom.addEventListener("touchcancel", endPinch); + + return { + destroy() { + // Never leave a half-finished preview behind. + if (gesture.pinching) { + view.dom.style.fontSize = gesture.originalInlineFontSize; + gesture.pinching = false; + } + dom.removeEventListener("touchstart", onTouchStart); + dom.removeEventListener("touchmove", onTouchMove); + dom.removeEventListener("touchend", endPinch); + dom.removeEventListener("touchcancel", endPinch); + }, + }; + }); +} \ No newline at end of file diff --git a/tests/unit/pinchZoom.test.ts b/tests/unit/pinchZoom.test.ts new file mode 100644 index 000000000..642f58d2a --- /dev/null +++ b/tests/unit/pinchZoom.test.ts @@ -0,0 +1,256 @@ +// @vitest-environment happy-dom + +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import pinchZoom, { + clampEditorFontSize, + computePinchFontSize, +} from "cm/pinchZoom"; + +const { settingsMock } = vi.hoisted(() => ({ + settingsMock: { + value: { fontSize: "12px" }, + update: vi.fn(), + }, +})); + +vi.mock("lib/settings", () => ({ default: settingsMock })); + +let now = 1_000_000; + +/** + * @param fontSize px size applied to the content element so that the computed + * style (which drives the pinch ratio) is deterministic in happy-dom. + */ +function createView(fontSize = "12px") { + const parent = document.createElement("div"); + document.body.append(parent); + settingsMock.value.fontSize = fontSize; + const view = new EditorView({ + state: EditorState.create({ + doc: "hello world", + extensions: [pinchZoom()], + }), + parent, + }); + view.dom.style.fontSize = fontSize; + return view; +} + +function fireTouch( + view: EditorView, + type: string, + points: Array<{ clientX: number; clientY: number }>, +) { + const event = new Event(type, { cancelable: true }); + Object.defineProperty(event, "touches", { value: points }); + view.dom.dispatchEvent(event); +} + +beforeEach(() => { + now = 1_000_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + settingsMock.value.fontSize = "12px"; + settingsMock.update.mockClear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; +}); + +describe("pinchZoom helpers", () => { + it("scales font size with the pinch ratio", () => { + expect(computePinchFontSize(12, 100, 200)).toBe(24); + expect(computePinchFontSize(24, 100, 50)).toBe(12); + }); + + it("clamps to the same 6-72px range as the font size commands", () => { + expect(computePinchFontSize(12, 100, 10)).toBe(6); + expect(computePinchFontSize(60, 100, 500)).toBe(72); + expect(clampEditorFontSize(77.4)).toBe(72); + }); + + it("falls back to 12px on invalid input", () => { + expect(computePinchFontSize(Number.NaN, 100, 200)).toBe(24); + expect(computePinchFontSize(12, 0, 200)).toBe(12); + }); +}); + +describe("pinchZoom gesture", () => { + it("spreading two fingers previews on the editor and persists once", () => { + const view = createView(); + + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0 }, + { clientX: 100, clientY: 0 }, + ]); + now += 60; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 200, clientY: 0 }, + ]); + + // Live preview is applied directly to the editor. + expect(view.dom.style.fontSize).toBe("24px"); + // Nothing is written to settings while the fingers are down. + expect(settingsMock.update).not.toHaveBeenCalled(); + + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); + + // One persist at gesture end: update(false). + expect(settingsMock.update).toHaveBeenCalledTimes(1); + expect(settingsMock.update).toHaveBeenCalledWith(false); + expect(settingsMock.value.fontSize).toBe("24px"); + // The inline preview is dropped so the rebuilt font theme takes over. + expect(view.dom.style.fontSize).toBe("12px"); + view.destroy(); + }); + + it("uses the displayed font size, not the raw setting, as the ratio base", () => { + // settings.json stores 1rem; the editor displays 16px. + const view = createView("16px"); + settingsMock.value.fontSize = "1rem"; + + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0 }, + { clientX: 100, clientY: 0 }, + ]); + now += 60; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 200, clientY: 0 }, + ]); + + // 16px * 200/100 = 32px (parsing "1rem" as 1 would clamp down to 6px). + expect(view.dom.style.fontSize).toBe("32px"); + view.destroy(); + }); + + it("pinching in clamps at 6px", () => { + const view = createView(); + + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0 }, + { clientX: 400, clientY: 0 }, + ]); + now += 60; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 50, clientY: 0 }, + ]); + + expect(view.dom.style.fontSize).toBe("6px"); + + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); + expect(settingsMock.value.fontSize).toBe("6px"); + view.destroy(); + }); + + it("throttles rapid touchmove events and applies the final size", () => { + const view = createView(); + + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0 }, + { clientX: 100, clientY: 0 }, + ]); + + now += 60; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 150, clientY: 0 }, + ]); + + expect(view.dom.style.fontSize).toBe("18px"); + + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 300, clientY: 0 }, + ]); + + // The second move is throttled during the gesture. + expect(view.dom.style.fontSize).toBe("18px"); + + // Ending the gesture must apply the latest pending distance. + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); + + expect(settingsMock.value.fontSize).toBe("36px"); + expect(settingsMock.update).toHaveBeenCalledWith(false); + + view.destroy(); + }); + + it("ignores single-finger touches", () => { + const view = createView(); + + fireTouch(view, "touchstart", [{ clientX: 0, clientY: 0 }]); + now += 60; + fireTouch(view, "touchmove", [{ clientX: 0, clientY: 400 }]); + + expect(view.dom.style.fontSize).toBe("12px"); + expect(settingsMock.update).not.toHaveBeenCalled(); + view.destroy(); + }); + + it("returning to the starting size saves nothing and shows the start size", () => { + const view = createView(); + + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0 }, + { clientX: 100, clientY: 0 }, + ]); + now += 60; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 200, clientY: 0 }, + ]); + expect(view.dom.style.fontSize).toBe("24px"); + + now += 60; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 100, clientY: 0 }, + ]); + expect(view.dom.style.fontSize).toBe("12px"); + + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); + + // The saved value never changed, so settings are never touched and the + // font theme (still 12px) matches what the editor shows. + expect(settingsMock.update).not.toHaveBeenCalled(); + expect(settingsMock.value.fontSize).toBe("12px"); + expect(view.dom.style.fontSize).toBe("12px"); + view.destroy(); + }); + + it("does not change a fractional size on a two-finger tap", () => { + const view = createView("9.5px"); + + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0 }, + { clientX: 100, clientY: 0 }, + ]); + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); + + expect(settingsMock.update).not.toHaveBeenCalled(); + expect(settingsMock.value.fontSize).toBe("9.5px"); + expect(view.dom.style.fontSize).toBe("9.5px"); + view.destroy(); + }); + + it("does not clamp an out-of-range size on a two-finger tap", () => { + const view = createView("99px"); + + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0 }, + { clientX: 100, clientY: 0 }, + ]); + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); + + expect(settingsMock.update).not.toHaveBeenCalled(); + expect(settingsMock.value.fontSize).toBe("99px"); + expect(view.dom.style.fontSize).toBe("99px"); + view.destroy(); + }); +}); \ No newline at end of file