From 853265ad3bc389b87fe7448acc7962586b1ad348 Mon Sep 17 00:00:00 2001 From: gahanad Date: Fri, 9 Oct 2026 13:53:41 +0530 Subject: [PATCH 01/10] 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 f825898d4a..aab943e0de 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 0000000000..ef755c050c --- /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 0000000000..11f9d83858 --- /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 02/10] 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 ef755c050c..8d68db5d57 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 11f9d83858..b6683f22f5 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 03/10] 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 8d68db5d57..139ff56329 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 b6683f22f5..3f4138c0d8 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 04/10] 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 139ff56329..0ac18d0a26 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 3f4138c0d8..642f58d2a1 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 From 7dc08716b257d0f199b07e6357f382b11786ee8e Mon Sep 17 00:00:00 2001 From: gahanad Date: Wed, 14 Oct 2026 02:22:01 +0530 Subject: [PATCH 05/10] Added pinch to zoom support to editor and made some changes --- src/cm/commandRegistry.js | 12 +- src/cm/editorFontSize.js | 24 + src/cm/mainEditorExtensions.ts | 2 +- src/cm/pinchZoom.ts | 410 +++++++++----- src/cm/touchSelectionMenu.js | 13 +- tests/unit/pinchZoom.test.ts | 748 +++++++++++++++++-------- tests/unit/quickToolsAdapterUi.test.js | 2 + 7 files changed, 833 insertions(+), 378 deletions(-) create mode 100644 src/cm/editorFontSize.js diff --git a/src/cm/commandRegistry.js b/src/cm/commandRegistry.js index 5d53cb1747..19a1a24fc9 100644 --- a/src/cm/commandRegistry.js +++ b/src/cm/commandRegistry.js @@ -108,6 +108,10 @@ import keyBindings, { } from "lib/keyBindings"; import settings from "lib/settings"; import Url from "utils/Url"; +import { + clampEditorFontSize, + DEFAULT_EDITOR_FONT_SIZE, +} from "./editorFontSize"; const commandKeymapCompartment = new Compartment(); @@ -1457,11 +1461,13 @@ async function openInAppBrowserCommand() { } function adjustFontSize(delta) { - const current = settings?.value?.fontSize || "12px"; - const numeric = Number.parseInt(current, 10) || 12; - const next = Math.min(72, Math.max(6, numeric + delta)); + const current = settings?.value?.fontSize || `${DEFAULT_EDITOR_FONT_SIZE}px`; + const numeric = Number.parseInt(current, 10) || DEFAULT_EDITOR_FONT_SIZE; + const next = clampEditorFontSize(numeric + delta); + settings.value.fontSize = `${next}px`; settings.update(false); + return true; } diff --git a/src/cm/editorFontSize.js b/src/cm/editorFontSize.js new file mode 100644 index 0000000000..22a0fc101b --- /dev/null +++ b/src/cm/editorFontSize.js @@ -0,0 +1,24 @@ +/** + * Shared font-size rules for the editor. + * Keeping these values consistent across editor commands and pinch-to-zoom. + */ + +export const DEFAULT_EDITOR_FONT_SIZE = 12; +export const MIN_EDITOR_FONT_SIZE = 6; +export const MAX_EDITOR_FONT_SIZE = 72; + +/** + * Keeps the editor font size within the supported range. + * @param {number} px + * @returns {number} + */ +export function clampEditorFontSize(px) { + if (!Number.isFinite(px)) { + return DEFAULT_EDITOR_FONT_SIZE; + } + + return Math.min( + MAX_EDITOR_FONT_SIZE, + Math.max(MIN_EDITOR_FONT_SIZE, Math.round(px)), + ); +} diff --git a/src/cm/mainEditorExtensions.ts b/src/cm/mainEditorExtensions.ts index aab943e0de..9ed4ee2b4a 100644 --- a/src/cm/mainEditorExtensions.ts +++ b/src/cm/mainEditorExtensions.ts @@ -48,7 +48,7 @@ export function createMainEditorExtensions( pushExtension(extensions, options.commandKeymapExtension); pushExtension(extensions, options.themeExtension); extensions.push(fixedHeightTheme); - extensions.push(pinchZoom()); + 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 index 0ac18d0a26..2a1a70983d 100644 --- a/src/cm/pinchZoom.ts +++ b/src/cm/pinchZoom.ts @@ -1,203 +1,321 @@ import { ViewPlugin } from "@codemirror/view"; import settings from "lib/settings"; +import { + DEFAULT_EDITOR_FONT_SIZE, + clampEditorFontSize, +} from "cm/editorFontSize"; const ZOOM_THROTTLE_MS = 50; -const MIN_FONT_SIZE = 6; -const MAX_FONT_SIZE = 72; -const DEFAULT_FONT_SIZE = 12; +const MOVE_THRESHOLD_PX = 15; +const PINCH_CONFIRMATION_MS = 120; + 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))); +interface TrackedTouch extends PinchPoint { + identifier: number; +} + +function distance(a: PinchPoint, b: PinchPoint): number { + return Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); } -/** - * 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, + currentDistance: number, ): number { if (!Number.isFinite(startPx) || startPx <= 0) { - startPx = DEFAULT_FONT_SIZE; + startPx = DEFAULT_EDITOR_FONT_SIZE; } if ( !Number.isFinite(startDistance) || startDistance <= 0 || - !Number.isFinite(distance) || - distance <= 0 + !Number.isFinite(currentDistance) || + currentDistance <= 0 ) { return clampEditorFontSize(startPx); } - return clampEditorFontSize((startPx * distance) / startDistance); + return clampEditorFontSize( + (startPx * currentDistance) / startDistance, + ); } -function touchDistance(a: PinchPoint, b: PinchPoint): number { - return Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); +function readFontSizePx(view: { dom: HTMLElement }): number { + const computed = Number.parseFloat(getComputedStyle(view.dom).fontSize); + return Number.isFinite(computed) && computed > 0 + ? computed + : DEFAULT_EDITOR_FONT_SIZE; } -/** - * 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; +function getTrackedTouch( + touches: TouchList, + identifier: number, +): TrackedTouch | undefined { + for (let i = 0; i < touches.length; i++) { + const touch = touches.item(i); + if (touch?.identifier === identifier) { + return { + identifier: touch.identifier, + clientX: touch.clientX, + clientY: touch.clientY, + }; + } + } + return undefined; +} + +function reportSettingsError(error: unknown): void { + console.error("Failed to save editor font size:", error); +} + +const pinchZoomExtension = ViewPlugin.define((view) => { + const gesture = { + pinching: false, + moved: false, + firstId: -1, + secondId: -1, + startDistance: 0, + startPx: DEFAULT_EDITOR_FONT_SIZE, + lastPx: DEFAULT_EDITOR_FONT_SIZE, + pendingDistance: 0, + lastUpdate: 0, + originalInlineFontSize: "", + candidateSince: 0, + candidateDistance: 0, + }; + + let destroyed = false; + let scheduledFrame: number | null = null; + + function requestLayoutMeasurement() { + view.requestMeasure(); + } - const match = String(settings?.value?.fontSize || `${DEFAULT_FONT_SIZE}px`) - .trim() - .match(/^(\d+(?:\.\d+)?)px$/i); + function applyPreview(px: number) { + px = clampEditorFontSize(px); + if (px === gesture.lastPx) return; - if (match) { - const value = Number.parseFloat(match[1]); - if (Number.isFinite(value) && value > 0) return value; + gesture.lastPx = px; + view.dom.style.fontSize = `${px}px`; + requestLayoutMeasurement(); } - return DEFAULT_FONT_SIZE; -} + function applyPendingPreview() { + applyPreview( + computePinchFontSize( + gesture.startPx, + gesture.startDistance, + gesture.pendingDistance, + ), + ); + } -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 cancelScheduledPreview() { + if (scheduledFrame != null) { + cancelAnimationFrame(scheduledFrame); + scheduledFrame = null; } + } - 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); - } + function clearGesture() { + gesture.pinching = false; + gesture.firstId = -1; + gesture.secondId = -1; + } - // Let the settings-driven font theme control the editor after the gesture. - view.dom.style.fontSize = gesture.originalInlineFontSize; + function restorePreview() { + view.dom.style.fontSize = gesture.originalInlineFontSize; + requestLayoutMeasurement(); + } + function persistFontSize() { + if (!gesture.moved || gesture.lastPx === gesture.startPx) { + restorePreview(); + return; } - function onTouchStart(event: TouchEvent) { - if (gesture.pinching || event.touches.length < 2) return; + settings.value.fontSize = `${gesture.lastPx}px`; - event.preventDefault(); + // Restore the inline preview immediately. The saved setting and + // font theme now control the editor's font size. + restorePreview(); - 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; - } + // Handle the asynchronous save without delaying touch handling. + void Promise.resolve(settings.update(false)).catch(reportSettingsError); + } - function onTouchMove(event: TouchEvent) { - if (!gesture.pinching || event.touches.length < 2) return; + function getCurrentTrackedTouches(event: TouchEvent) { + const first = getTrackedTouch(event.touches, gesture.firstId); + const second = getTrackedTouch(event.touches, gesture.secondId); + + if (!first || !second) return undefined; + return { first, second }; + } + + function onTouchStart(event: TouchEvent) { + if (gesture.pinching || event.touches.length < 2) return; + + const first = event.touches.item(0); + const second = event.touches.item(1); + if (!first || !second) return; + + // This event is recognized as the start of a two-finger gesture. + event.preventDefault(); + event.stopPropagation(); + + gesture.pinching = true; + gesture.moved = false; + gesture.candidateSince = 0; + gesture.candidateDistance = 0; + gesture.firstId = first.identifier; + gesture.secondId = second.identifier; + gesture.startDistance = distance(first, second); + gesture.pendingDistance = gesture.startDistance; + gesture.startPx = readFontSizePx(view); + gesture.lastPx = gesture.startPx; + gesture.lastUpdate = 0; + gesture.originalInlineFontSize = view.dom.style.fontSize; + } + + function onTouchMove(event: TouchEvent) { + if (!gesture.pinching) return; + if (!event.cancelable) { + restorePreview(); + clearGesture(); + return; + } + const touches = getCurrentTrackedTouches(event); + + // Do not silently switch to a third finger if an original finger leaves. + if (!touches) { event.preventDefault(); + event.stopPropagation(); + return; + } - // 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], - ); + event.preventDefault(); + event.stopPropagation(); + const currentDistance = distance(touches.first, touches.second); + gesture.pendingDistance = currentDistance; + if (!gesture.moved) { + const distanceChange = currentDistance - gesture.startDistance; + const absDistanceChange = Math.abs(distanceChange); const now = Date.now(); - if (now - gesture.lastUpdate < ZOOM_THROTTLE_MS) return; - gesture.lastUpdate = now; + if (absDistanceChange < MOVE_THRESHOLD_PX) { + gesture.candidateSince = 0; + gesture.candidateDistance = 0; + return; + } - applyFontSize( - computePinchFontSize( - gesture.startPx, - gesture.startDistance, - gesture.pendingDistance, - ), + if (gesture.candidateSince === 0) { + gesture.candidateSince = now; + gesture.candidateDistance = currentDistance; + return; + } + + const initialDirection = Math.sign( + gesture.candidateDistance - gesture.startDistance, ); - } + const currentDirection = Math.sign(distanceChange); + + if (initialDirection !== currentDirection) { + gesture.candidateSince = now; + gesture.candidateDistance = currentDistance; + return; + } - 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, - ), - ); + if (now - gesture.candidateSince < PINCH_CONFIRMATION_MS) { + return; } - gesture.pinching = false; - persistFontSize(); + gesture.moved = true; + } + + const now = Date.now(); + if (now - gesture.lastUpdate >= ZOOM_THROTTLE_MS) { + gesture.lastUpdate = now; + applyPendingPreview(); + return; + } + + // Inside the throttle window: coalesce into the next animation frame so + // the latest pending distance is applied and the preview never goes stale. + if (scheduledFrame == null) { + scheduledFrame = requestAnimationFrame(() => { + scheduledFrame = null; + if (destroyed || !gesture.pinching) return; + applyPendingPreview(); + }); + } + + } + + function endPinch(event: TouchEvent) { + if (!gesture.pinching) return; + + const originalTouchesRemain = + getTrackedTouch(event.touches, gesture.firstId) && + getTrackedTouch(event.touches, gesture.secondId); + + // If both original fingers remain, a third finger was added or removed. + // Keep the original gesture alive rather than restarting it. + if (originalTouchesRemain) return; + + event.preventDefault(); + event.stopPropagation(); + + cancelScheduledPreview(); + + // Apply the latest movement even if the throttle skipped its preview. + if (gesture.moved) { + applyPendingPreview(); } - 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); - }, - }; + clearGesture(); + persistFontSize(); + } + + const target = view.contentDOM; + + target.addEventListener("touchstart", onTouchStart, { + passive: false, + capture: true, + }); + target.addEventListener("touchmove", onTouchMove, { + passive: false, + capture: true, + }); + target.addEventListener("touchend", endPinch, { + capture: true, + }); + target.addEventListener("touchcancel", endPinch, { + capture: true, }); -} \ No newline at end of file + + return { + destroy() { + destroyed = true; + cancelScheduledPreview(); + if (gesture.pinching) { + clearGesture(); + restorePreview(); + } + + target.removeEventListener("touchstart", onTouchStart, true); + target.removeEventListener("touchmove", onTouchMove, true); + target.removeEventListener("touchend", endPinch, true); + target.removeEventListener("touchcancel", endPinch, true); + }, + }; +}); + +// Reuse the same plugin definition for every editor. +export default pinchZoomExtension; diff --git a/src/cm/touchSelectionMenu.js b/src/cm/touchSelectionMenu.js index b017acb1fb..e26260bdb1 100644 --- a/src/cm/touchSelectionMenu.js +++ b/src/cm/touchSelectionMenu.js @@ -214,9 +214,19 @@ class TouchSelectionMenuController { this.$menu.className = "cursor-menu"; this.#bindEvents(); } - + #onPinchStart = (event) => { + // A two-finger gesture takes priority: cancel any captured tap/selection + // bookkeeping so nothing commits when a finger lifts mid-pinch (pinch zoom + // on the editor, see cm/pinchZoom). + if (event.touches.length < 2) return; + this.#pointerSelectionSession = null; + this.#readOnlyTapSession = null; + this.#pendingPointerSelectionClick = null; + this.#clearMenuShowTimer(); + }; #bindEvents() { const root = this.#view.dom; + root.addEventListener("touchStart", this.#onPinchStart, true); root.addEventListener("contextmenu", this.#onContextMenu, true); document.addEventListener("pointerdown", this.#onGlobalPointerDown, true); document.addEventListener("pointermove", this.#onGlobalPointerMove, true); @@ -236,6 +246,7 @@ class TouchSelectionMenuController { } this.#showMenuDeferred(); }); + root.removeEventListener("touchstart", this.#onPinchStart, true); this.#tooltipObserver.observe(this.#view.dom, { childList: true, subtree: true, diff --git a/tests/unit/pinchZoom.test.ts b/tests/unit/pinchZoom.test.ts index 642f58d2a1..2582c0ba03 100644 --- a/tests/unit/pinchZoom.test.ts +++ b/tests/unit/pinchZoom.test.ts @@ -1,256 +1,550 @@ -// @vitest-environment happy-dom + + // @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"; + computePinchFontSize, +} from "../../src/cm/pinchZoom"; +import { clampEditorFontSize } from "../../src/cm/editorFontSize"; const { settingsMock } = vi.hoisted(() => ({ - settingsMock: { - value: { fontSize: "12px" }, - update: vi.fn(), - }, + 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; + 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 }>, + view: EditorView, + type: string, + points: Array<{ + clientX: number; + clientY: number; + identifier?: number; + }>, ) { - const event = new Event(type, { cancelable: true }); - Object.defineProperty(event, "touches", { value: points }); - view.dom.dispatchEvent(event); + const touches = points.map((point, index) => ({ + ...point, + identifier: point.identifier ?? index + 1, + target: view.contentDOM, + })); + + const event = new Event(type, { + cancelable: true, + bubbles: true, + }); + + for (const property of ["touches", "changedTouches", "targetTouches"]) { + Object.defineProperty(event, property, { + value: Object.assign([...touches], { + item(index: number) { + return touches[index] ?? null; + }, + }), + }); + } + + view.contentDOM.dispatchEvent(event); } - +let frameQueue: Array<() => void> = []; beforeEach(() => { - now = 1_000_000; - vi.spyOn(Date, "now").mockImplementation(() => now); - settingsMock.value.fontSize = "12px"; - settingsMock.update.mockClear(); + frameQueue = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + frameQueue.push(() => cb(0)); + return frameQueue.length; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {}); + now = 1_000_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + settingsMock.value.fontSize = "12px"; + settingsMock.update.mockClear(); }); +function flushFrames() { + const queue = frameQueue; + frameQueue = []; + queue.forEach((run) => run()); +} afterEach(() => { - vi.restoreAllMocks(); - document.body.innerHTML = ""; + 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); - }); + 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 + it("applies the movement dropped by the throttle on the next frame", () => { + 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 }, + ]); + + now += 121; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 150, clientY: 0 }, + ]); + + expect(view.dom.style.fontSize).toBe("18px"); + + // Inside the throttle window: not applied synchronously, queued instead. + now += 10; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 300, clientY: 0 }, + ]); + + expect(view.dom.style.fontSize).toBe("18px"); + + flushFrames(); + + expect(view.dom.style.fontSize).toBe("36px"); + + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); + + expect(settingsMock.value.fontSize).toBe("36px"); + expect(settingsMock.update).toHaveBeenCalledTimes(1); + + view.destroy(); + }); + + it("does not apply a scheduled frame after the view is destroyed", () => { + 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 }, + ]); + + now += 121; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 150, clientY: 0 }, + ]); + + now += 10; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 300, clientY: 0 }, + ]); + + view.destroy(); + flushFrames(); + + expect(settingsMock.update).not.toHaveBeenCalled(); + expect(settingsMock.value.fontSize).toBe("12px"); + }); + it("keeps the original pair when a third finger lands and ends when one lifts", () => { + 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 }, + ]); + + now += 121; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 200, clientY: 0 }, + ]); + + expect(view.dom.style.fontSize).toBe("24px"); + + // Third finger lands: ignored while pinching. + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0, identifier: 1 }, + { clientX: 200, clientY: 0, identifier: 2 }, + { clientX: 60, clientY: 60, identifier: 3 }, + ]); + + now += 60; + // Reordered touch list (B, C, A) must still measure A-B = 200, not B-C. + fireTouch(view, "touchmove", [ + { clientX: 200, clientY: 0, identifier: 2 }, + { clientX: 60, clientY: 60, identifier: 3 }, + { clientX: 0, clientY: 0, identifier: 1 }, + ]); + + expect(view.dom.style.fontSize).toBe("24px"); + expect(settingsMock.update).not.toHaveBeenCalled(); + + // Original finger A lifts: remaining touches are B and C. + fireTouch(view, "touchend", [ + { clientX: 200, clientY: 0, identifier: 2 }, + { clientX: 60, clientY: 60, identifier: 3 }, + ]); + + expect(settingsMock.update).toHaveBeenCalledTimes(1); + expect(settingsMock.update).toHaveBeenCalledWith(false); + expect(settingsMock.value.fontSize).toBe("24px"); + expect(view.dom.style.fontSize).toBe("12px"); + + view.destroy(); + }); + it("ignores sub-pixel finger jitter and does not save or clamp", () => { + const view = createView("9.5px"); + + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0 }, + { clientX: 100, clientY: 0 }, + ]); + + now += 60; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0.4 }, + { clientX: 100.4, clientY: 0 }, + ]); + + now += 121; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0.4 }, + { clientX: 100.4, clientY: 0 }, + ]); + + expect(view.dom.style.fontSize).toBe("9.5px"); + + 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("ignores movement below the 15px move threshold even after the confirmation delay", () => { + const view = createView("99px"); + + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0 }, + { clientX: 100, clientY: 0 }, + ]); + + now += 60; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 110, clientY: 0 }, + ]); + + now += 121; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 110, clientY: 0 }, + ]); + + expect(view.dom.style.fontSize).toBe("99px"); + + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); + + expect(settingsMock.update).not.toHaveBeenCalled(); + expect(settingsMock.value.fontSize).toBe("99px"); + + view.destroy(); + }); + 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 }, + ]); + + expect(view.dom.style.fontSize).toBe("12px"); + + now += 121; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 200, clientY: 0 }, + ]); + + expect(view.dom.style.fontSize).toBe("24px"); + expect(settingsMock.update).not.toHaveBeenCalled(); + + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); + + expect(settingsMock.update).toHaveBeenCalledTimes(1); + expect(settingsMock.update).toHaveBeenCalledWith(false); + expect(settingsMock.value.fontSize).toBe("24px"); + expect(view.dom.style.fontSize).toBe("12px"); + + view.destroy(); + }); + + it("uses the displayed font size, not the raw setting, as the ratio base", () => { + 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 }, + ]); + + expect(view.dom.style.fontSize).toBe("16px"); + + now += 121; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 200, clientY: 0 }, + ]); + + 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: 200, clientY: 0 }, + ]); + + expect(view.dom.style.fontSize).toBe("12px"); + + now += 121; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 200, 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 }, + ]); + + // First move starts the confirmation window. + now += 60; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 150, clientY: 0 }, + ]); + + expect(view.dom.style.fontSize).toBe("12px"); + + // Confirm after the required delay. + now += 121; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 150, clientY: 0 }, + ]); + + expect(view.dom.style.fontSize).toBe("18px"); + + // This movement occurs inside the throttle window. + now += 10; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 300, clientY: 0 }, + ]); + + expect(view.dom.style.fontSize).toBe("18px"); + + // Ending the gesture applies 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("does not zoom after a brief movement before confirmation", () => { + const view = createView(); + + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0 }, + { clientX: 100, clientY: 0 }, + ]); + + now += 60; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 120, clientY: 0 }, + ]); + + expect(view.dom.style.fontSize).toBe("12px"); + + // Release before the confirmation window completes. + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); + + expect(settingsMock.update).not.toHaveBeenCalled(); + expect(settingsMock.value.fontSize).toBe("12px"); + + 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 }, + ]); + + now += 121; + 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 }]); + + 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(); + }); +}); diff --git a/tests/unit/quickToolsAdapterUi.test.js b/tests/unit/quickToolsAdapterUi.test.js index 6a2d1450ca..73f35d8f27 100644 --- a/tests/unit/quickToolsAdapterUi.test.js +++ b/tests/unit/quickToolsAdapterUi.test.js @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { afterEach, expect, it, vi } from "vitest"; import { loadSourceModule } from "../helpers/loadSourceModule"; +import { clampEditorFontSize, DEFAULT_EDITOR_FONT_SIZE } from "cm/editorFontSize"; function mockQuickTools() { vi.doMock("components/quickTools", async () => { @@ -147,6 +148,7 @@ function hostCommands(manager, exec) { "src/cm/commandRegistry.js", { ...dependencies, + "./editorFontSize": { clampEditorFontSize, DEFAULT_EDITOR_FONT_SIZE }, "@codemirror/state": { Compartment: class {} }, "cm/keyBindingUtils": { toCodeMirrorKey: () => null }, "lib/keyBindings": { From 3be290da38ad7c1c08c74f68e4aec74d75f96ae8 Mon Sep 17 00:00:00 2001 From: gahanad Date: Wed, 14 Oct 2026 02:56:09 +0530 Subject: [PATCH 06/10] Fixed corrections for pinchZoom --- src/cm/pinchZoom.ts | 15 +++++++++++ src/cm/touchSelectionMenu.js | 4 +-- tests/unit/pinchZoom.test.ts | 52 +++++++++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/cm/pinchZoom.ts b/src/cm/pinchZoom.ts index 2a1a70983d..94555b344f 100644 --- a/src/cm/pinchZoom.ts +++ b/src/cm/pinchZoom.ts @@ -273,6 +273,21 @@ const pinchZoomExtension = ViewPlugin.define((view) => { event.stopPropagation(); cancelScheduledPreview(); + // A pinch can be held still after spreading (no further touchmove). + // Confirm the sustained candidate so the zoom is not silently lost. + if (!gesture.moved && gesture.candidateSince !== 0) { + const distanceChange = + gesture.pendingDistance - gesture.startDistance; + const initialDirection = Math.sign( + gesture.candidateDistance - gesture.startDistance, + ); + if ( + Date.now() - gesture.candidateSince >= PINCH_CONFIRMATION_MS && + Math.sign(distanceChange) === initialDirection + ) { + gesture.moved = true; + } + } // Apply the latest movement even if the throttle skipped its preview. if (gesture.moved) { diff --git a/src/cm/touchSelectionMenu.js b/src/cm/touchSelectionMenu.js index e26260bdb1..a0831b3f25 100644 --- a/src/cm/touchSelectionMenu.js +++ b/src/cm/touchSelectionMenu.js @@ -226,7 +226,7 @@ class TouchSelectionMenuController { }; #bindEvents() { const root = this.#view.dom; - root.addEventListener("touchStart", this.#onPinchStart, true); + root.addEventListener("touchstart", this.#onPinchStart, true); root.addEventListener("contextmenu", this.#onContextMenu, true); document.addEventListener("pointerdown", this.#onGlobalPointerDown, true); document.addEventListener("pointermove", this.#onGlobalPointerMove, true); @@ -246,7 +246,6 @@ class TouchSelectionMenuController { } this.#showMenuDeferred(); }); - root.removeEventListener("touchstart", this.#onPinchStart, true); this.#tooltipObserver.observe(this.#view.dom, { childList: true, subtree: true, @@ -256,6 +255,7 @@ class TouchSelectionMenuController { destroy() { const root = this.#view.dom; root.removeEventListener("contextmenu", this.#onContextMenu, true); + root.removeEventListener("touchstart", this.#onPinchStart, true); document.removeEventListener( "pointerdown", this.#onGlobalPointerDown, diff --git a/tests/unit/pinchZoom.test.ts b/tests/unit/pinchZoom.test.ts index 2582c0ba03..e215111c5c 100644 --- a/tests/unit/pinchZoom.test.ts +++ b/tests/unit/pinchZoom.test.ts @@ -112,7 +112,57 @@ describe("pinchZoom helpers", () => { }); describe("pinchZoom gesture", () => { - it("applies the movement dropped by the throttle on the next frame", () => { + it("confirms a held pinch that ends without another touchmove", () => { + 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 }, + ]); + + // Held still past the confirmation delay: no further touchmove. + now += 121; + + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); + + expect(settingsMock.value.fontSize).toBe("24px"); + expect(settingsMock.update).toHaveBeenCalledTimes(1); + expect(settingsMock.update).toHaveBeenCalledWith(false); + expect(view.dom.style.fontSize).toBe("12px"); + + view.destroy(); + }); + + it("still ignores a brief spread released inside the confirmation delay", () => { + 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 }, + ]); + + // Released only 50ms after the candidate movement was recorded. + now += 50; + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); + + expect(settingsMock.update).not.toHaveBeenCalled(); + expect(settingsMock.value.fontSize).toBe("12px"); + + view.destroy(); + }); + it("applies the movement dropped by the throttle on the next frame", () => { const view = createView(); fireTouch(view, "touchstart", [ From b5e7a0bddd885c42eb0bcba7f1e2e14a0d326128 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 06:52:52 +0000 Subject: [PATCH 07/10] chore(deps): bump ghcr.io/devcontainers/features/node (#2961) Bumps ghcr.io/devcontainers/features/node from 2.1.0 to 2.1.1. --- updated-dependencies: - dependency-name: ghcr.io/devcontainers/features/node dependency-version: 2.1.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .devcontainer/devcontainer-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 78f3facc47..08e381109d 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -1,9 +1,9 @@ { "features": { "ghcr.io/devcontainers/features/node:2.1": { - "version": "2.1.0", - "resolved": "ghcr.io/devcontainers/features/node@sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857", - "integrity": "sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857" + "version": "2.1.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:2a19d3b0b3879437637cfd292eb29fee0afcfbe6364e33db9afc4f80c5c1f956", + "integrity": "sha256:2a19d3b0b3879437637cfd292eb29fee0afcfbe6364e33db9afc4f80c5c1f956" }, "ghcr.io/nordcominc/devcontainer-features/android-sdk:2": { "version": "2.0.0", From 90f745a26cb99dad96cfd9367cf351bf5072109b Mon Sep 17 00:00:00 2001 From: Emmanuel Lobo <76094069+UnschooledGamer@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:01:43 +0000 Subject: [PATCH 08/10] chore: fix Discord badge URL in README (#2965) Updated Discord badge URL to a new endpoint. --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 6732b02471..4ac444fff3 100644 --- a/readme.md +++ b/readme.md @@ -4,7 +4,7 @@

-[![](https://img.shields.io/endpoint?logo=telegram&label=Acode&style=flat&url=https%3A%2F%2Facode.app%2Fapi%2Ftelegram-members-count)](https://t.me/foxdebug_acode) [![](https://dcbadge.vercel.app/api/server/vVxVWYUAWD?style=flat)](https://discord.gg/vVxVWYUAWD) +[![](https://img.shields.io/endpoint?logo=telegram&label=Acode&style=flat&url=https%3A%2F%2Facode.app%2Fapi%2Ftelegram-members-count)](https://t.me/foxdebug_acode) [![](https://dcbadge.limes.pink/api/server/https://discord.gg/vVxVWYUAWD?style=flat)](https://discord.gg/vVxVWYUAWD) ## • Overview From 0401b265a85c6b7c25160c18148f30031a012e8d Mon Sep 17 00:00:00 2001 From: Aadin Date: Tue, 13 Oct 2026 10:28:17 +0000 Subject: [PATCH 09/10] feat(browser): set a default device size for the in-app preview (#2966) * refactor(browser): keep a list of devices in the emulator device list * refactor(browser): register each device when added to the list * feat(browser): apply the saved default preview size once layout is ready * feat(browser): save and restore a default device preview size * feat(browser): add "Set as Default" and "Clear Default" menu items * feat(browser): handle "Set as Default" and "Clear Default" actions * feat(browser): show default menu items only while Devices mode is on * feat(browser): open the preview in Devices mode when a default is set * feat(browser): restore the default preview mode on startup * refactor(browser): allow applying desktop mode without reloading * refactor(browser): make the reload in setDesktopMode optional * fix(browser): apply saved desktop mode before the first load to avoid a reload * fix(browser): only apply dimensions after layout, not desktop mode * fix(browser): re-fit saved presets to the current screen on restore * fix(browser): reload once after the saved size is applied * fix(browser): don't resize the webview before the emulator is set up * fix(browser): add reloadAfterLayout helper, only when a page was requested * fix(browser): show "Clear Default" only when a default is saved * fix(browser): refresh default menu items after Set as Default * fix(browser): refresh default menu items after Clear Default --- .../android/com/foxdebug/browser/Browser.java | 103 ++++++++++++++++- .../com/foxdebug/browser/Emulator.java | 106 ++++++++++++++++++ 2 files changed, 208 insertions(+), 1 deletion(-) diff --git a/src/plugins/browser/android/com/foxdebug/browser/Browser.java b/src/plugins/browser/android/com/foxdebug/browser/Browser.java index f12597159b..bd0d4ab93d 100644 --- a/src/plugins/browser/android/com/foxdebug/browser/Browser.java +++ b/src/plugins/browser/android/com/foxdebug/browser/Browser.java @@ -227,6 +227,7 @@ public void onDownloadStart(String url, String userAgent, createMenu(); addView(titleLayout); addView(webViewContainer); + restoreEmulator(); } private void createMenu() { @@ -236,6 +237,10 @@ private void createMenu() { menu.addItem(Ui.Icons.DEVICES, "Devices", false); menu.addItem(Ui.Icons.NO_CACHE, "Disable Cache", false); menu.addItem(Ui.Icons.TERMINAL, "Console", false); + menu.addItem(Ui.Icons.TUNE, "Set as Default"); + menu.addItem(Ui.Icons.REFRESH, "Clear Default"); + menu.setVisible("Set as Default", false); + menu.setVisible("Clear Default", false); menu.addItem(Ui.Icons.OPEN_IN_BROWSER, "Open in Browser"); menu.addItem(Ui.Icons.EXIT, "Exit"); @@ -250,6 +255,7 @@ public void onSelect(String action, Boolean checked) { } emulator = checked; + updateDefaultItems(checked); if (checked) { setDesktopMode(true); setConsoleVisible(false); @@ -284,6 +290,26 @@ public void onGlobalLayout() { case "Console": setConsoleVisible(checked); break; + case "Set as Default": + if (deviceEmulator != null) { + deviceEmulator.saveAsDefault(); + updateDefaultItems(emulator); + Toast + .makeText( + context, + "Saved as default preview", + Toast.LENGTH_SHORT + ) + .show(); + } + break; + case "Clear Default": + Emulator.clearDefault(context); + updateDefaultItems(emulator); + Toast + .makeText(context, "Default cleared", Toast.LENGTH_SHORT) + .show(); + break; case "Disable Cache": webView .getSettings() @@ -308,6 +334,75 @@ public void onGlobalLayout() { ); } + private void updateDefaultItems(boolean emulatorOn) { + menu.setVisible("Set as Default", emulatorOn); + menu.setVisible( + "Clear Default", + emulatorOn && Emulator.hasDefault(context) + ); + } + + private void reloadAfterLayout() { + // If no page was requested yet, the first load will already happen at + // the saved size, so there is nothing to reload. + if (url.isEmpty()) return; + + webView + .getViewTreeObserver() + .addOnGlobalLayoutListener( + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + webView.getViewTreeObserver().removeOnGlobalLayoutListener(this); + webView.reload(); + } + } + ); + } + + private void restoreEmulator() { + if (onlyConsole) return; + if (!Emulator.hasDefault(context)) return; + + // Apply the saved mode before the first navigation so the page is not + // loaded with the wrong user agent and then reloaded. + setDesktopMode(true, false); + + webView + .getViewTreeObserver() + .addOnGlobalLayoutListener( + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + if (webView.getMeasuredWidth() == 0) return; + webView.getViewTreeObserver().removeOnGlobalLayoutListener(this); + + if (deviceEmulator == null) { + createDeviceEmulatorLayout(); + deviceEmulator.setRestoredListener( + new Runnable() { + @Override + public void run() { + reloadAfterLayout(); + } + } + ); + } + + emulator = true; + menu.setChecked("Devices", true); + updateDefaultItems(true); + setConsoleVisible(false); + menu.setChecked("Console", false); + menu.setVisible("Console", false); + addView(deviceEmulator); + // The emulator sets up its sliders on a later layout pass and then + // applies the saved size itself, so the webview is not resized here. + } + } + ); + } + private void createDeviceEmulatorLayout() { Browser browser = this; deviceEmulator = new Emulator(context, theme); @@ -339,6 +434,10 @@ private void setTextViewProperties(TextView textView, int height) { } private void setDesktopMode(boolean enabled) { + setDesktopMode(enabled, true); + } + + private void setDesktopMode(boolean enabled, boolean reload) { int width = 0; int height = 0; WebSettings webSettings = webView.getSettings(); @@ -360,7 +459,9 @@ private void setDesktopMode(boolean enabled) { webSettings.setLoadWithOverviewMode(enabled); webSettings.setSupportZoom(enabled); webSettings.setBuiltInZoomControls(enabled); - webView.reload(); + if (reload) { + webView.reload(); + } } public void setDesktopMode() { diff --git a/src/plugins/browser/android/com/foxdebug/browser/Emulator.java b/src/plugins/browser/android/com/foxdebug/browser/Emulator.java index adb65f2b1f..25f0d70eae 100644 --- a/src/plugins/browser/android/com/foxdebug/browser/Emulator.java +++ b/src/plugins/browser/android/com/foxdebug/browser/Emulator.java @@ -163,6 +163,8 @@ public void onGlobalLayout() { if (listener != null) { listener.onChange(iWidth, correctedHeight, 1); } + + restoreDefaultDevice(); } } ); @@ -302,6 +304,108 @@ private void selectDevice(Device device) { listener.onChange(width, height, maxScale / 100f); } + public static boolean hasDefault(Context context) { + return context + .getSharedPreferences("acode_browser", Context.MODE_PRIVATE) + .getBoolean("default_set", false); + } + + public static void clearDefault(Context context) { + context + .getSharedPreferences("acode_browser", Context.MODE_PRIVATE) + .edit() + .putBoolean("default_set", false) + .apply(); + } + + public void saveAsDefault() { + context + .getSharedPreferences("acode_browser", Context.MODE_PRIVATE) + .edit() + .putBoolean("default_set", true) + .putInt("default_width", getWidthProgress()) + .putInt("default_height", getHeightProgress()) + .putInt("default_scale", seekBars.get("scale").getProgress()) + .putString( + "default_device", + selectedDevice != null ? selectedDevice.name : "Custom" + ) + .apply(); + } + + private Runnable restoredListener; + + public void setRestoredListener(Runnable listener) { + restoredListener = listener; + } + + private void restoreDefaultDevice() { + if (!hasDefault(context)) return; + + android.content.SharedPreferences prefs = context.getSharedPreferences( + "acode_browser", + Context.MODE_PRIVATE + ); + SeekBar widthSeekBar = seekBars.get("width"); + SeekBar heightSeekBar = seekBars.get("height"); + SeekBar scaleSeekBar = seekBars.get("scale"); + String name = prefs.getString("default_device", "Custom"); + + Device preset = null; + for (Device device : deviceListView.devices) { + if (device.id != customDevice.id && device.name.equals(name)) { + preset = device; + break; + } + } + + if (preset != null && listener != null) { + // A preset is re-fitted to the current screen, so it keeps its shape if + // the screen size changed since it was saved (rotation, split-screen, + // foldables). Only the scale is restored from the saved value. + if (deviceListView.selectedDeviceView != null) { + deviceListView.selectedDeviceView.deselect(); + } + deviceListView.select(preset); + selectDevice(preset); + + int scale = Math.min( + prefs.getInt("default_scale", scaleSeekBar.getMax()), + scaleSeekBar.getMax() + ); + scaleSeekBar.setProgress(scale); + listener.onChange(getWidthProgress(), getHeightProgress(), scale / 100f); + } else { + // Custom sizes are restored from the saved width/height. + int width = Math.min( + prefs.getInt("default_width", widthSeekBar.getMax()), + widthSeekBar.getMax() + ); + int height = Math.min( + prefs.getInt("default_height", heightSeekBar.getMax()), + heightSeekBar.getMax() + ); + + widthSeekBar.setProgress(width); + heightSeekBar.setProgress(height); + setMaxScale(width, height); + int scale = Math.min( + prefs.getInt("default_scale", 100), + scaleSeekBar.getMax() + ); + scaleSeekBar.setProgress(scale); + selectedDevice = customDevice; + + if (listener != null) { + listener.onChange(width, height, scale / 100f); + } + } + + if (restoredListener != null) { + restoredListener.run(); + } + } + private void setMaxScale(int width, int height) { SeekBar scaleSeekBar = seekBars.get("scale"); SeekBar widthSeekBar = seekBars.get("width"); @@ -367,6 +471,7 @@ public void deselect() { class DeviceListView extends ScrollView { DeviceView selectedDeviceView; + ArrayList devices = new ArrayList(); LinearLayout deviceListLayout; Callback callback; Context context; @@ -407,6 +512,7 @@ public void select(Device device) { } public void add(Device device) { + devices.add(device); DeviceView deviceView = new DeviceView(context, device, theme); deviceListLayout.addView(deviceView); From cf1635e3903aedeb9bffba4650e6bd2f8da6cbb8 Mon Sep 17 00:00:00 2001 From: gahanad Date: Fri, 16 Oct 2026 02:40:34 +0530 Subject: [PATCH 10/10] Added pinchZoom correction --- src/cm/pinchZoom.ts | 50 ++++++++++++++++++------ tests/unit/pinchZoom.test.ts | 73 ++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 12 deletions(-) diff --git a/src/cm/pinchZoom.ts b/src/cm/pinchZoom.ts index 94555b344f..a414c1dd01 100644 --- a/src/cm/pinchZoom.ts +++ b/src/cm/pinchZoom.ts @@ -8,6 +8,7 @@ import { const ZOOM_THROTTLE_MS = 50; const MOVE_THRESHOLD_PX = 15; const PINCH_CONFIRMATION_MS = 120; +const GESTURE_LISTENER_OPTIONS = { capture: true, passive: false } as const; interface PinchPoint { @@ -123,6 +124,18 @@ const pinchZoomExtension = ViewPlugin.define((view) => { } } + function addGestureListeners() { + document.addEventListener("touchmove", onTouchMove, GESTURE_LISTENER_OPTIONS); + document.addEventListener("touchend", endPinch, GESTURE_LISTENER_OPTIONS); + document.addEventListener("touchcancel", endPinch, GESTURE_LISTENER_OPTIONS); + } + + function removeGestureListeners() { + document.removeEventListener("touchmove", onTouchMove, true); + document.removeEventListener("touchend", endPinch, true); + document.removeEventListener("touchcancel", endPinch, true); + } + function clearGesture() { gesture.pinching = false; gesture.firstId = -1; @@ -159,7 +172,23 @@ const pinchZoomExtension = ViewPlugin.define((view) => { } function onTouchStart(event: TouchEvent) { - if (gesture.pinching || event.touches.length < 2) return; + if (gesture.pinching) { + // Recovery path: if the original fingers' move/end events were + // lost (CodeMirror unmounted the line under a finger), the + // gesture is stuck. Abandon it so the next pinch can start. + const fingerStillDown = + getTrackedTouch(event.touches, gesture.firstId) || + getTrackedTouch(event.touches, gesture.secondId); + if (fingerStillDown) return; + cancelScheduledPreview(); + removeGestureListeners(); + clearGesture(); + restorePreview(); + } + + // Start only when exactly two fingers land together; a three-finger + // touch keeps Acode's normal behaviour (scrolling etc.). + if (event.touches.length !== 2) return; const first = event.touches.item(0); const second = event.touches.item(1); @@ -181,11 +210,17 @@ const pinchZoomExtension = ViewPlugin.define((view) => { gesture.lastPx = gesture.startPx; gesture.lastUpdate = 0; gesture.originalInlineFontSize = view.dom.style.fontSize; + + // The rest of the gesture is observed on document: a finger's + // original target (.cm-line) can be unmounted by the preview + // re-measure, and events for a detached node never bubble back. + addGestureListeners(); } function onTouchMove(event: TouchEvent) { if (!gesture.pinching) return; if (!event.cancelable) { + removeGestureListeners(); restorePreview(); clearGesture(); return; @@ -268,7 +303,7 @@ const pinchZoomExtension = ViewPlugin.define((view) => { // If both original fingers remain, a third finger was added or removed. // Keep the original gesture alive rather than restarting it. if (originalTouchesRemain) return; - + removeGestureListeners(); event.preventDefault(); event.stopPropagation(); @@ -304,21 +339,12 @@ const pinchZoomExtension = ViewPlugin.define((view) => { passive: false, capture: true, }); - target.addEventListener("touchmove", onTouchMove, { - passive: false, - capture: true, - }); - target.addEventListener("touchend", endPinch, { - capture: true, - }); - target.addEventListener("touchcancel", endPinch, { - capture: true, - }); return { destroy() { destroyed = true; cancelScheduledPreview(); + removeGestureListeners(); if (gesture.pinching) { clearGesture(); restorePreview(); diff --git a/tests/unit/pinchZoom.test.ts b/tests/unit/pinchZoom.test.ts index e215111c5c..d0fcb4067f 100644 --- a/tests/unit/pinchZoom.test.ts +++ b/tests/unit/pinchZoom.test.ts @@ -45,6 +45,7 @@ function fireTouch( clientY: number; identifier?: number; }>, + target: EventTarget = view.contentDOM ) { const touches = points.map((point, index) => ({ ...point, @@ -597,4 +598,76 @@ describe("pinchZoom gesture", () => { view.destroy(); }); + + it("completes a pinch whose move and end reach the document only", () => { + 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 }, + ], document); + + now += 121; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 200, clientY: 0 }, + ], document); + + expect(view.dom.style.fontSize).toBe("24px"); + + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }], document); + + expect(settingsMock.value.fontSize).toBe("24px"); + expect(settingsMock.update).toHaveBeenCalledTimes(1); + + view.destroy(); + }); + + it("recovers when the gesture events are lost to an unmounted line", () => { + const view = createView(); + const detached = document.createElement("div"); + + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0 }, + { clientX: 100, clientY: 0 }, + ]); + + now += 60; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 200, clientY: 0 }, + ], detached); + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }], detached); + + // Events went nowhere; the stuck gesture must not save anything. + expect(settingsMock.update).not.toHaveBeenCalled(); + + // A fresh two-finger touchstart revives the pinch. + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0 }, + { clientX: 100, clientY: 0 }, + ]); + now += 60; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 200, clientY: 0 }, + ]); + now += 121; + fireTouch(view, "touchmove", [ + { clientX: 0, clientY: 0 }, + { clientX: 200, clientY: 0 }, + ]); + fireTouch(view, "touchend", [{ clientX: 0, clientY: 0 }]); + + expect(settingsMock.value.fontSize).toBe("24px"); + expect(settingsMock.update).toHaveBeenCalledTimes(1); + + view.destroy(); + }); });