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://img.shields.io/endpoint?logo=telegram&label=Acode&style=flat&url=https%3A%2F%2Facode.app%2Fapi%2Ftelegram-members-count)](https://t.me/foxdebug_acode) [![](https://dcbadge.vercel.app/api/server/vVxVWYUAWD?style=flat)](https://discord.gg/vVxVWYUAWD) +[![](https://img.shields.io/endpoint?logo=telegram&label=Acode&style=flat&url=https%3A%2F%2Facode.app%2Fapi%2Ftelegram-members-count)](https://t.me/foxdebug_acode) [![](https://dcbadge.limes.pink/api/server/https://discord.gg/vVxVWYUAWD?style=flat)](https://discord.gg/vVxVWYUAWD) ## • Overview 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": {