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://t.me/foxdebug_acode) [](https://discord.gg/vVxVWYUAWD)
+[](https://t.me/foxdebug_acode) [](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();
+ });
});