From 853265ad3bc389b87fe7448acc7962586b1ad348 Mon Sep 17 00:00:00 2001 From: gahanad Date: Fri, 9 Oct 2026 13:53:41 +0530 Subject: [PATCH 1/4] feat: add pinch-to-zoom support to editor --- src/cm/mainEditorExtensions.ts | 2 + src/cm/pinchZoom.ts | 117 ++++++++++++++++++++++++ tests/unit/pinchZoom.test.ts | 161 +++++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 src/cm/pinchZoom.ts create mode 100644 tests/unit/pinchZoom.test.ts 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..ef755c050 --- /dev/null +++ b/src/cm/pinchZoom.ts @@ -0,0 +1,117 @@ +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 (px) when the gesture started + * @param startDistance distance (px) between the two touches when the gesture started + * @param distance current distance (px) 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) + ) { + 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); +} + +function readFontSizePx(): number { + const current = settings?.value?.fontSize || `${DEFAULT_FONT_SIZE}px`; + const numeric = Number.parseInt(String(current), 10); + return numeric > 0 ? numeric : DEFAULT_FONT_SIZE; +} + +export default function pinchZoom() { + return ViewPlugin.define((view) => { + const gesture = { + pinching: false, + startDistance: 0, + startPx: DEFAULT_FONT_SIZE, + lastPx: DEFAULT_FONT_SIZE, + lastUpdate: 0, + }; + + function applyFontSize(px: number) { + if (px === gesture.lastPx) return; + gesture.lastPx = px; + settings.value.fontSize = `${px}px`; + settings.update(undefined, false, false); + } + + function persistFontSize() { + if (gesture.lastPx === gesture.startPx) return; + settings.update(false); + } + + function onTouchStart(event: TouchEvent) { + if (event.touches.length < 2) return; + event.preventDefault(); + gesture.pinching = true; + gesture.startDistance = touchDistance(event.touches[0], event.touches[1]); + gesture.startPx = readFontSizePx(); + gesture.lastPx = gesture.startPx; + gesture.lastUpdate = 0; + } + + function onTouchMove(event: TouchEvent) { + if (!gesture.pinching || event.touches.length < 2) return; + event.preventDefault(); + const now = Date.now(); + if (now - gesture.lastUpdate < ZOOM_THROTTLE_MS) return; + gesture.lastUpdate = now; + const distance = touchDistance(event.touches[0], event.touches[1]); + applyFontSize( + computePinchFontSize(gesture.startPx, gesture.startDistance, distance), + ); + } + + function endPinch(event: TouchEvent) { + if (!gesture.pinching) return; + if (event.touches.length >= 2) return; + 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() { + dom.removeEventListener("touchstart", onTouchStart); + dom.removeEventListener("touchmove", onTouchMove); + dom.removeEventListener("touchend", endPinch); + dom.removeEventListener("touchcancel", endPinch); + }, + }; + }); +} diff --git a/tests/unit/pinchZoom.test.ts b/tests/unit/pinchZoom.test.ts new file mode 100644 index 000000000..11f9d8385 --- /dev/null +++ b/tests/unit/pinchZoom.test.ts @@ -0,0 +1,161 @@ +// @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; + +function createView() { + const parent = document.createElement("div"); + document.body.append(parent); + return new EditorView({ + state: EditorState.create({ + doc: "hello world", + extensions: [pinchZoom()], + }), + parent, + }); +} + +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 increases fontSize 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 }, + ]); + + expect(settingsMock.value.fontSize).toBe("24px"); + // Live update, no save: update(undefined, false, false) + expect(settingsMock.update).toHaveBeenCalledWith(undefined, false, false); + + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); + // One persist at gesture end: update(false) + expect(settingsMock.update).toHaveBeenCalledWith(false); + 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(settingsMock.value.fontSize).toBe("6px"); + view.destroy(); + }); + + it("throttles rapid touchmove events", () => { + 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 }, + ]); + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 300, clientY: 0 }, + ]); + + // Second move is inside the 50ms window -> size from first move only + expect(settingsMock.value.fontSize).toBe("18px"); + 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(settingsMock.value.fontSize).toBe("12px"); + expect(settingsMock.update).not.toHaveBeenCalled(); + view.destroy(); + }); + + it("does not persist when the gesture ends at the starting size", () => { + const view = createView(); + + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0 }, + { clientX: 100, clientY: 0 }, + ]); + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); + + expect(settingsMock.update).not.toHaveBeenCalled(); + view.destroy(); + }); +}); \ No newline at end of file From 1cf50efb5e4c6e4795fc408151550c6be33db6fd Mon Sep 17 00:00:00 2001 From: gahanad Date: Fri, 9 Oct 2026 14:56:08 +0530 Subject: [PATCH 2/4] fix: address pinch-to-zoom review feedback --- src/cm/pinchZoom.ts | 240 ++++++++++++++++++++++++----------- tests/unit/pinchZoom.test.ts | 15 ++- 2 files changed, 179 insertions(+), 76 deletions(-) diff --git a/src/cm/pinchZoom.ts b/src/cm/pinchZoom.ts index ef755c050..8d68db5d5 100644 --- a/src/cm/pinchZoom.ts +++ b/src/cm/pinchZoom.ts @@ -1,3 +1,4 @@ + import { ViewPlugin } from "@codemirror/view"; import settings from "lib/settings"; @@ -18,23 +19,28 @@ export function clampEditorFontSize(px: number): number { /** * Convert a pinch distance ratio into an absolute font size. - * @param startPx font size (px) when the gesture started - * @param startDistance distance (px) between the two touches when the gesture started - * @param distance current distance (px) between the two touches + * @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(startPx) || startPx <= 0) { + startPx = DEFAULT_FONT_SIZE; + } + if ( !Number.isFinite(startDistance) || startDistance <= 0 || - !Number.isFinite(distance) + !Number.isFinite(distance) || + distance <= 0 ) { return clampEditorFontSize(startPx); } + return clampEditorFontSize((startPx * distance) / startDistance); } @@ -42,76 +48,162 @@ function touchDistance(a: PinchPoint, b: PinchPoint): number { return Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); } -function readFontSizePx(): number { - const current = settings?.value?.fontSize || `${DEFAULT_FONT_SIZE}px`; - const numeric = Number.parseInt(String(current), 10); - return numeric > 0 ? numeric : DEFAULT_FONT_SIZE; +function readFontSizePx(view: { contentDOM: HTMLElement }): number { + const configuredSize = String( + settings?.value?.fontSize || `${DEFAULT_FONT_SIZE}px`, + ).trim(); + + const match = configuredSize.match( + /^(\d+(?:\.\d+)?)(px|rem|em|pt)?$/i, + ); + + if (match) { + const value = Number.parseFloat(match[1]); + const unit = (match[2] || "px").toLowerCase(); + + if (Number.isFinite(value) && value > 0) { + switch (unit) { + case "rem": + return value * Number.parseFloat( + getComputedStyle(document.documentElement).fontSize || "16", + ); + case "em": + return value * readComputedFontSize(view); + case "pt": + return value * (96 / 72); + default: + return value; + } + } + } + + return readComputedFontSize(view); +} + +function readComputedFontSize(view: { contentDOM: HTMLElement }): number { + const computedSize = Number.parseFloat( + getComputedStyle(view.contentDOM).fontSize, + ); + + return Number.isFinite(computedSize) && computedSize > 0 + ? computedSize + : DEFAULT_FONT_SIZE; } export default function pinchZoom() { - return ViewPlugin.define((view) => { - const gesture = { - pinching: false, - startDistance: 0, - startPx: DEFAULT_FONT_SIZE, - lastPx: DEFAULT_FONT_SIZE, - lastUpdate: 0, - }; - - function applyFontSize(px: number) { - if (px === gesture.lastPx) return; - gesture.lastPx = px; - settings.value.fontSize = `${px}px`; - settings.update(undefined, false, false); - } - - function persistFontSize() { - if (gesture.lastPx === gesture.startPx) return; - settings.update(false); - } - - function onTouchStart(event: TouchEvent) { - if (event.touches.length < 2) return; - event.preventDefault(); - gesture.pinching = true; - gesture.startDistance = touchDistance(event.touches[0], event.touches[1]); - gesture.startPx = readFontSizePx(); - gesture.lastPx = gesture.startPx; - gesture.lastUpdate = 0; - } - - function onTouchMove(event: TouchEvent) { - if (!gesture.pinching || event.touches.length < 2) return; - event.preventDefault(); - const now = Date.now(); - if (now - gesture.lastUpdate < ZOOM_THROTTLE_MS) return; - gesture.lastUpdate = now; - const distance = touchDistance(event.touches[0], event.touches[1]); - applyFontSize( - computePinchFontSize(gesture.startPx, gesture.startDistance, distance), - ); - } - - function endPinch(event: TouchEvent) { - if (!gesture.pinching) return; - if (event.touches.length >= 2) return; - 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() { - dom.removeEventListener("touchstart", onTouchStart); - dom.removeEventListener("touchmove", onTouchMove); - dom.removeEventListener("touchend", endPinch); - dom.removeEventListener("touchcancel", endPinch); - }, - }; - }); + return ViewPlugin.define((view) => { + const gesture = { + pinching: false, + startDistance: 0, + startPx: DEFAULT_FONT_SIZE, + lastPx: DEFAULT_FONT_SIZE, + pendingDistance: 0, + lastUpdate: 0, + originalInlineFontSize: "", + originalSetting: "", + }; + + function applyFontSize(px: number) { + px = clampEditorFontSize(px); + if (px === gesture.lastPx) return; + + gesture.lastPx = px; + + // Preview directly so returning to the saved size also updates the editor. + view.contentDOM.style.fontSize = `${px}px`; + + settings.value.fontSize = `${px}px`; + settings.update(undefined, false, false); + } + + function persistFontSize() { + if (gesture.lastPx !== gesture.startPx) { + settings.value.fontSize = `${gesture.lastPx}px`; + settings.update(false); + } else { + // Restore the original unit/value if the gesture made no net change. + settings.value.fontSize = gesture.originalSetting; + } + + // Let the normal settings styles control the editor after the gesture. + view.contentDOM.style.fontSize = gesture.originalInlineFontSize; + } + + function onTouchStart(event: TouchEvent) { + if (gesture.pinching || event.touches.length < 2) return; + + event.preventDefault(); + + gesture.pinching = true; + 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.contentDOM.style.fontSize; + gesture.originalSetting = String( + settings.value.fontSize || `${DEFAULT_FONT_SIZE}px`, + ); + } + + function onTouchMove(event: TouchEvent) { + if (!gesture.pinching || event.touches.length < 2) return; + + event.preventDefault(); + + // Always remember the latest movement, even if the preview is throttled. + 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; + + // Apply the final movement even if it fell inside the throttle window. + 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() { + dom.removeEventListener("touchstart", onTouchStart); + dom.removeEventListener("touchmove", onTouchMove); + dom.removeEventListener("touchend", endPinch); + dom.removeEventListener("touchcancel", endPinch); + }, + }; + }); } diff --git a/tests/unit/pinchZoom.test.ts b/tests/unit/pinchZoom.test.ts index 11f9d8385..b6683f22f 100644 --- a/tests/unit/pinchZoom.test.ts +++ b/tests/unit/pinchZoom.test.ts @@ -112,25 +112,36 @@ describe("pinchZoom gesture", () => { view.destroy(); }); - it("throttles rapid touchmove events", () => { + 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(settingsMock.value.fontSize).toBe("18px"); + fireTouch(view, "touchmove", [ { clientX: 0, clientY: 0 }, { clientX: 300, clientY: 0 }, ]); - // Second move is inside the 50ms window -> size from first move only + // The second move is throttled during the gesture. expect(settingsMock.value.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(); }); From 2e298a1ed6bd461dfdd68d12dbfa2dd387d6ae28 Mon Sep 17 00:00:00 2001 From: gahanad Date: Fri, 9 Oct 2026 16:02:46 +0530 Subject: [PATCH 3/4] fix: address pinch-to-zoom review feedback --- src/cm/pinchZoom.ts | 106 +++++++++++++++++------------------ tests/unit/pinchZoom.test.ts | 106 +++++++++++++++++++++++++++++++---- 2 files changed, 145 insertions(+), 67 deletions(-) diff --git a/src/cm/pinchZoom.ts b/src/cm/pinchZoom.ts index 8d68db5d5..139ff5632 100644 --- a/src/cm/pinchZoom.ts +++ b/src/cm/pinchZoom.ts @@ -1,4 +1,3 @@ - import { ViewPlugin } from "@codemirror/view"; import settings from "lib/settings"; @@ -48,59 +47,44 @@ 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 content element 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: { contentDOM: HTMLElement }): number { - const configuredSize = String( - settings?.value?.fontSize || `${DEFAULT_FONT_SIZE}px`, - ).trim(); - - const match = configuredSize.match( - /^(\d+(?:\.\d+)?)(px|rem|em|pt)?$/i, + const computed = Number.parseFloat( + getComputedStyle(view.contentDOM).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]); - const unit = (match[2] || "px").toLowerCase(); - - if (Number.isFinite(value) && value > 0) { - switch (unit) { - case "rem": - return value * Number.parseFloat( - getComputedStyle(document.documentElement).fontSize || "16", - ); - case "em": - return value * readComputedFontSize(view); - case "pt": - return value * (96 / 72); - default: - return value; - } - } + if (Number.isFinite(value) && value > 0) return value; } - return readComputedFontSize(view); -} - -function readComputedFontSize(view: { contentDOM: HTMLElement }): number { - const computedSize = Number.parseFloat( - getComputedStyle(view.contentDOM).fontSize, - ); - - return Number.isFinite(computedSize) && computedSize > 0 - ? computedSize - : DEFAULT_FONT_SIZE; + 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: "", - originalSetting: "", }; function applyFontSize(px: number) { @@ -109,23 +93,24 @@ export default function pinchZoom() { gesture.lastPx = px; - // Preview directly so returning to the saved size also updates the editor. + // 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.contentDOM.style.fontSize = `${px}px`; - - settings.value.fontSize = `${px}px`; - settings.update(undefined, false, false); } 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); - } else { - // Restore the original unit/value if the gesture made no net change. - settings.value.fontSize = gesture.originalSetting; } - // Let the normal settings styles control the editor after the gesture. + // Let the settings-driven font theme control the editor after the gesture. view.contentDOM.style.fontSize = gesture.originalInlineFontSize; } @@ -135,6 +120,7 @@ export default function pinchZoom() { event.preventDefault(); gesture.pinching = true; + gesture.moved = false; gesture.startDistance = touchDistance( event.touches[0], event.touches[1], @@ -144,9 +130,6 @@ export default function pinchZoom() { gesture.pendingDistance = gesture.startDistance; gesture.lastUpdate = 0; gesture.originalInlineFontSize = view.contentDOM.style.fontSize; - gesture.originalSetting = String( - settings.value.fontSize || `${DEFAULT_FONT_SIZE}px`, - ); } function onTouchMove(event: TouchEvent) { @@ -154,7 +137,9 @@ export default function pinchZoom() { event.preventDefault(); - // Always remember the latest movement, even if the preview is throttled. + // 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], @@ -177,14 +162,18 @@ export default function pinchZoom() { function endPinch(event: TouchEvent) { if (!gesture.pinching || event.touches.length >= 2) return; - // Apply the final movement even if it fell inside the throttle window. - applyFontSize( - computePinchFontSize( - gesture.startPx, - gesture.startDistance, - gesture.pendingDistance, - ), - ); + // 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(); @@ -199,6 +188,11 @@ export default function pinchZoom() { return { destroy() { + // Never leave a half-finished preview behind. + if (gesture.pinching) { + view.contentDOM.style.fontSize = gesture.originalInlineFontSize; + gesture.pinching = false; + } dom.removeEventListener("touchstart", onTouchStart); dom.removeEventListener("touchmove", onTouchMove); dom.removeEventListener("touchend", endPinch); @@ -206,4 +200,4 @@ export default function pinchZoom() { }, }; }); -} +} \ No newline at end of file diff --git a/tests/unit/pinchZoom.test.ts b/tests/unit/pinchZoom.test.ts index b6683f22f..3f4138c0d 100644 --- a/tests/unit/pinchZoom.test.ts +++ b/tests/unit/pinchZoom.test.ts @@ -19,16 +19,23 @@ vi.mock("lib/settings", () => ({ default: settingsMock })); let now = 1_000_000; -function createView() { +/** + * @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); - return new EditorView({ + settingsMock.value.fontSize = fontSize; + const view = new EditorView({ state: EditorState.create({ doc: "hello world", extensions: [pinchZoom()], }), parent, }); + view.contentDOM.style.fontSize = fontSize; + return view; } function fireTouch( @@ -72,7 +79,7 @@ describe("pinchZoom helpers", () => { }); describe("pinchZoom gesture", () => { - it("spreading two fingers increases fontSize and persists once", () => { + it("spreading two fingers previews on the editor and persists once", () => { const view = createView(); fireTouch(view, "touchstart", [ @@ -85,13 +92,39 @@ describe("pinchZoom gesture", () => { { clientX: 200, clientY: 0 }, ]); - expect(settingsMock.value.fontSize).toBe("24px"); - // Live update, no save: update(undefined, false, false) - expect(settingsMock.update).toHaveBeenCalledWith(undefined, false, false); + // Live preview is applied directly to the editor. + expect(view.contentDOM.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) + + // 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.contentDOM.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.contentDOM.style.fontSize).toBe("32px"); view.destroy(); }); @@ -108,6 +141,9 @@ describe("pinchZoom gesture", () => { { clientX: 50, clientY: 0 }, ]); + expect(view.contentDOM.style.fontSize).toBe("6px"); + + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); expect(settingsMock.value.fontSize).toBe("6px"); view.destroy(); }); @@ -126,7 +162,7 @@ describe("pinchZoom gesture", () => { { clientX: 150, clientY: 0 }, ]); - expect(settingsMock.value.fontSize).toBe("18px"); + expect(view.contentDOM.style.fontSize).toBe("18px"); fireTouch(view, "touchmove", [ { clientX: 0, clientY: 0 }, @@ -134,7 +170,7 @@ describe("pinchZoom gesture", () => { ]); // The second move is throttled during the gesture. - expect(settingsMock.value.fontSize).toBe("18px"); + expect(view.contentDOM.style.fontSize).toBe("18px"); // Ending the gesture must apply the latest pending distance. fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); @@ -152,14 +188,60 @@ describe("pinchZoom gesture", () => { now += 60; fireTouch(view, "touchmove", [{ clientX: 0, clientY: 400 }]); - expect(settingsMock.value.fontSize).toBe("12px"); + expect(view.contentDOM.style.fontSize).toBe("12px"); expect(settingsMock.update).not.toHaveBeenCalled(); view.destroy(); }); - it("does not persist when the gesture ends at the starting size", () => { + 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.contentDOM.style.fontSize).toBe("24px"); + + now += 60; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 100, clientY: 0 }, + ]); + expect(view.contentDOM.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.contentDOM.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.contentDOM.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 }, @@ -167,6 +249,8 @@ describe("pinchZoom gesture", () => { fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); expect(settingsMock.update).not.toHaveBeenCalled(); + expect(settingsMock.value.fontSize).toBe("99px"); + expect(view.contentDOM.style.fontSize).toBe("99px"); view.destroy(); }); }); \ No newline at end of file From b771e77c9bcb07755c04c5422cda7d0e907b5522 Mon Sep 17 00:00:00 2001 From: gahanad Date: Fri, 9 Oct 2026 16:52:46 +0530 Subject: [PATCH 4/4] fix: address pinch-to-zoom review feedback --- src/cm/pinchZoom.ts | 16 ++++++++-------- tests/unit/pinchZoom.test.ts | 26 +++++++++++++------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/cm/pinchZoom.ts b/src/cm/pinchZoom.ts index 139ff5632..0ac18d0a2 100644 --- a/src/cm/pinchZoom.ts +++ b/src/cm/pinchZoom.ts @@ -51,14 +51,14 @@ function touchDistance(a: PinchPoint, b: PinchPoint): number { * 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 content element is the only reliable base for the pinch + * 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: { contentDOM: HTMLElement }): number { +function readFontSizePx(view: { dom: HTMLElement }): number { const computed = Number.parseFloat( - getComputedStyle(view.contentDOM).fontSize, + getComputedStyle(view.dom).fontSize, ); if (Number.isFinite(computed) && computed > 0) return computed; @@ -98,7 +98,7 @@ export default function pinchZoom() { // 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.contentDOM.style.fontSize = `${px}px`; + view.dom.style.fontSize = `${px}px`; } function persistFontSize() { @@ -111,7 +111,7 @@ export default function pinchZoom() { } // Let the settings-driven font theme control the editor after the gesture. - view.contentDOM.style.fontSize = gesture.originalInlineFontSize; + view.dom.style.fontSize = gesture.originalInlineFontSize; } function onTouchStart(event: TouchEvent) { @@ -129,7 +129,7 @@ export default function pinchZoom() { gesture.lastPx = gesture.startPx; gesture.pendingDistance = gesture.startDistance; gesture.lastUpdate = 0; - gesture.originalInlineFontSize = view.contentDOM.style.fontSize; + gesture.originalInlineFontSize = view.dom.style.fontSize; } function onTouchMove(event: TouchEvent) { @@ -190,7 +190,7 @@ export default function pinchZoom() { destroy() { // Never leave a half-finished preview behind. if (gesture.pinching) { - view.contentDOM.style.fontSize = gesture.originalInlineFontSize; + view.dom.style.fontSize = gesture.originalInlineFontSize; gesture.pinching = false; } dom.removeEventListener("touchstart", onTouchStart); diff --git a/tests/unit/pinchZoom.test.ts b/tests/unit/pinchZoom.test.ts index 3f4138c0d..642f58d2a 100644 --- a/tests/unit/pinchZoom.test.ts +++ b/tests/unit/pinchZoom.test.ts @@ -34,7 +34,7 @@ function createView(fontSize = "12px") { }), parent, }); - view.contentDOM.style.fontSize = fontSize; + view.dom.style.fontSize = fontSize; return view; } @@ -93,7 +93,7 @@ describe("pinchZoom gesture", () => { ]); // Live preview is applied directly to the editor. - expect(view.contentDOM.style.fontSize).toBe("24px"); + expect(view.dom.style.fontSize).toBe("24px"); // Nothing is written to settings while the fingers are down. expect(settingsMock.update).not.toHaveBeenCalled(); @@ -104,7 +104,7 @@ describe("pinchZoom gesture", () => { 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.contentDOM.style.fontSize).toBe("12px"); + expect(view.dom.style.fontSize).toBe("12px"); view.destroy(); }); @@ -124,7 +124,7 @@ describe("pinchZoom gesture", () => { ]); // 16px * 200/100 = 32px (parsing "1rem" as 1 would clamp down to 6px). - expect(view.contentDOM.style.fontSize).toBe("32px"); + expect(view.dom.style.fontSize).toBe("32px"); view.destroy(); }); @@ -141,7 +141,7 @@ describe("pinchZoom gesture", () => { { clientX: 50, clientY: 0 }, ]); - expect(view.contentDOM.style.fontSize).toBe("6px"); + expect(view.dom.style.fontSize).toBe("6px"); fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); expect(settingsMock.value.fontSize).toBe("6px"); @@ -162,7 +162,7 @@ describe("pinchZoom gesture", () => { { clientX: 150, clientY: 0 }, ]); - expect(view.contentDOM.style.fontSize).toBe("18px"); + expect(view.dom.style.fontSize).toBe("18px"); fireTouch(view, "touchmove", [ { clientX: 0, clientY: 0 }, @@ -170,7 +170,7 @@ describe("pinchZoom gesture", () => { ]); // The second move is throttled during the gesture. - expect(view.contentDOM.style.fontSize).toBe("18px"); + expect(view.dom.style.fontSize).toBe("18px"); // Ending the gesture must apply the latest pending distance. fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); @@ -188,7 +188,7 @@ describe("pinchZoom gesture", () => { now += 60; fireTouch(view, "touchmove", [{ clientX: 0, clientY: 400 }]); - expect(view.contentDOM.style.fontSize).toBe("12px"); + expect(view.dom.style.fontSize).toBe("12px"); expect(settingsMock.update).not.toHaveBeenCalled(); view.destroy(); }); @@ -205,14 +205,14 @@ describe("pinchZoom gesture", () => { { clientX: 0, clientY: 0 }, { clientX: 200, clientY: 0 }, ]); - expect(view.contentDOM.style.fontSize).toBe("24px"); + expect(view.dom.style.fontSize).toBe("24px"); now += 60; fireTouch(view, "touchmove", [ { clientX: 0, clientY: 0 }, { clientX: 100, clientY: 0 }, ]); - expect(view.contentDOM.style.fontSize).toBe("12px"); + expect(view.dom.style.fontSize).toBe("12px"); fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); @@ -220,7 +220,7 @@ describe("pinchZoom gesture", () => { // font theme (still 12px) matches what the editor shows. expect(settingsMock.update).not.toHaveBeenCalled(); expect(settingsMock.value.fontSize).toBe("12px"); - expect(view.contentDOM.style.fontSize).toBe("12px"); + expect(view.dom.style.fontSize).toBe("12px"); view.destroy(); }); @@ -235,7 +235,7 @@ describe("pinchZoom gesture", () => { expect(settingsMock.update).not.toHaveBeenCalled(); expect(settingsMock.value.fontSize).toBe("9.5px"); - expect(view.contentDOM.style.fontSize).toBe("9.5px"); + expect(view.dom.style.fontSize).toBe("9.5px"); view.destroy(); }); @@ -250,7 +250,7 @@ describe("pinchZoom gesture", () => { expect(settingsMock.update).not.toHaveBeenCalled(); expect(settingsMock.value.fontSize).toBe("99px"); - expect(view.contentDOM.style.fontSize).toBe("99px"); + expect(view.dom.style.fontSize).toBe("99px"); view.destroy(); }); }); \ No newline at end of file