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",
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
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 f825898d4a..9ed4ee2b4a 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..a414c1dd01
--- /dev/null
+++ b/src/cm/pinchZoom.ts
@@ -0,0 +1,362 @@
+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 MOVE_THRESHOLD_PX = 15;
+const PINCH_CONFIRMATION_MS = 120;
+const GESTURE_LISTENER_OPTIONS = { capture: true, passive: false } as const;
+
+
+interface PinchPoint {
+ clientX: number;
+ clientY: number;
+}
+
+interface TrackedTouch extends PinchPoint {
+ identifier: number;
+}
+
+function distance(a: PinchPoint, b: PinchPoint): number {
+ return Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
+}
+
+export function computePinchFontSize(
+ startPx: number,
+ startDistance: number,
+ currentDistance: number,
+): number {
+ if (!Number.isFinite(startPx) || startPx <= 0) {
+ startPx = DEFAULT_EDITOR_FONT_SIZE;
+ }
+
+ if (
+ !Number.isFinite(startDistance) ||
+ startDistance <= 0 ||
+ !Number.isFinite(currentDistance) ||
+ currentDistance <= 0
+ ) {
+ return clampEditorFontSize(startPx);
+ }
+
+ return clampEditorFontSize(
+ (startPx * currentDistance) / startDistance,
+ );
+}
+
+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;
+}
+
+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();
+ }
+
+ function applyPreview(px: number) {
+ px = clampEditorFontSize(px);
+ if (px === gesture.lastPx) return;
+
+ gesture.lastPx = px;
+ view.dom.style.fontSize = `${px}px`;
+ requestLayoutMeasurement();
+ }
+
+ function applyPendingPreview() {
+ applyPreview(
+ computePinchFontSize(
+ gesture.startPx,
+ gesture.startDistance,
+ gesture.pendingDistance,
+ ),
+ );
+ }
+
+ function cancelScheduledPreview() {
+ if (scheduledFrame != null) {
+ cancelAnimationFrame(scheduledFrame);
+ scheduledFrame = null;
+ }
+ }
+
+ 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;
+ gesture.secondId = -1;
+ }
+
+ function restorePreview() {
+ view.dom.style.fontSize = gesture.originalInlineFontSize;
+ requestLayoutMeasurement();
+ }
+ function persistFontSize() {
+ if (!gesture.moved || gesture.lastPx === gesture.startPx) {
+ restorePreview();
+ return;
+ }
+
+ settings.value.fontSize = `${gesture.lastPx}px`;
+
+ // Restore the inline preview immediately. The saved setting and
+ // font theme now control the editor's font size.
+ restorePreview();
+
+ // Handle the asynchronous save without delaying touch handling.
+ void Promise.resolve(settings.update(false)).catch(reportSettingsError);
+ }
+
+
+ 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) {
+ // 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);
+ 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;
+
+ // 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;
+ }
+ const touches = getCurrentTrackedTouches(event);
+
+ // Do not silently switch to a third finger if an original finger leaves.
+ if (!touches) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ 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 (absDistanceChange < MOVE_THRESHOLD_PX) {
+ gesture.candidateSince = 0;
+ gesture.candidateDistance = 0;
+ return;
+ }
+
+ 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;
+ }
+
+ if (now - gesture.candidateSince < PINCH_CONFIRMATION_MS) {
+ return;
+ }
+
+ 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;
+ removeGestureListeners();
+ event.preventDefault();
+ 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) {
+ applyPendingPreview();
+ }
+
+ clearGesture();
+ persistFontSize();
+ }
+
+ const target = view.contentDOM;
+
+ target.addEventListener("touchstart", onTouchStart, {
+ passive: false,
+ capture: true,
+ });
+
+ return {
+ destroy() {
+ destroyed = true;
+ cancelScheduledPreview();
+ removeGestureListeners();
+ 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..a0831b3f25 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);
@@ -245,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/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);
diff --git a/tests/unit/pinchZoom.test.ts b/tests/unit/pinchZoom.test.ts
new file mode 100644
index 0000000000..d0fcb4067f
--- /dev/null
+++ b/tests/unit/pinchZoom.test.ts
@@ -0,0 +1,673 @@
+
+ // @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, {
+ computePinchFontSize,
+} from "../../src/cm/pinchZoom";
+import { clampEditorFontSize } from "../../src/cm/editorFontSize";
+
+const { settingsMock } = vi.hoisted(() => ({
+ settingsMock: {
+ value: { fontSize: "12px" },
+ update: vi.fn(),
+ },
+}));
+
+vi.mock("lib/settings", () => ({ default: settingsMock }));
+
+let now = 1_000_000;
+
+function createView(fontSize = "12px") {
+ const parent = document.createElement("div");
+ document.body.append(parent);
+ settingsMock.value.fontSize = fontSize;
+
+ const view = new EditorView({
+ state: EditorState.create({
+ doc: "hello world",
+ extensions: [pinchZoom],
+ }),
+ parent,
+ });
+
+ view.dom.style.fontSize = fontSize;
+ return view;
+}
+
+function fireTouch(
+ view: EditorView,
+ type: string,
+ points: Array<{
+ clientX: number;
+ clientY: number;
+ identifier?: number;
+ }>,
+ target: EventTarget = view.contentDOM
+) {
+ 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(() => {
+ 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 = "";
+});
+
+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("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", [
+ { 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();
+ });
+
+ 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();
+ });
+});
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": {