diff --git a/src/cm/commandRegistry.js b/src/cm/commandRegistry.js index 5d53cb174..19a1a24fc 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 000000000..22a0fc101 --- /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 f825898d4..24efe855b 100644 --- a/src/cm/mainEditorExtensions.ts +++ b/src/cm/mainEditorExtensions.ts @@ -1,5 +1,6 @@ import type { Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; +import pinchZoom from "./pinchZoom"; import searchMatchHighlighter from "./searchMatchHighlighter"; interface MainEditorExtensionOptions { @@ -47,6 +48,7 @@ export function createMainEditorExtensions( pushExtension(extensions, options.commandKeymapExtension); pushExtension(extensions, options.themeExtension); extensions.push(fixedHeightTheme); + extensions.push(pinchZoom); pushExtension(extensions, options.pointerCursorVisibilityExtension); pushExtension(extensions, options.shiftClickSelectionExtension); pushExtension(extensions, options.multiCursorSelectionExtension); diff --git a/src/cm/pinchZoom.ts b/src/cm/pinchZoom.ts new file mode 100644 index 000000000..a50c0a918 --- /dev/null +++ b/src/cm/pinchZoom.ts @@ -0,0 +1,364 @@ +import { ViewPlugin } from "@codemirror/view"; +import { + clampEditorFontSize, + DEFAULT_EDITOR_FONT_SIZE, +} from "cm/editorFontSize"; +import settings from "lib/settings"; + +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 b017acb1f..a0831b3f2 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/tests/unit/pinchZoom.test.ts b/tests/unit/pinchZoom.test.ts new file mode 100644 index 000000000..a63cb7662 --- /dev/null +++ b/tests/unit/pinchZoom.test.ts @@ -0,0 +1,798 @@ +// @vitest-environment happy-dom + +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { clampEditorFontSize } from "../../src/cm/editorFontSize"; +import pinchZoom, { computePinchFontSize } from "../../src/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(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, + })); + + 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; + }, + }), + }); + } + + target.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(); + }); + it("keeps pinching when move/end events reach document instead of the line", () => { + const view = createView(); + + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0 }, + { clientX: 100, clientY: 0 }, + ]); + + // The .cm-line under a finger was unmounted, so the remaining + // move/end events are only delivered to document. + 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.update).toHaveBeenCalledTimes(1); + expect(settingsMock.update).toHaveBeenCalledWith(false); + expect(settingsMock.value.fontSize).toBe("24px"); + expect(view.dom.style.fontSize).toBe("12px"); + + view.destroy(); + }); + + it("abandons a stuck pinch so the next two-finger gesture can start", () => { + const view = createView(); + + fireTouch(view, "touchstart", [ + { clientX: 0, clientY: 0, identifier: 1 }, + { clientX: 100, clientY: 0, identifier: 2 }, + ]); + + now += 60; + fireTouch( + view, + "touchmove", + [ + { clientX: 0, clientY: 0, identifier: 1 }, + { clientX: 200, clientY: 0, identifier: 2 }, + ], + document, + ); + + now += 121; + fireTouch( + view, + "touchmove", + [ + { clientX: 0, clientY: 0, identifier: 1 }, + { clientX: 200, clientY: 0, identifier: 2 }, + ], + document, + ); + + expect(view.dom.style.fontSize).toBe("24px"); + + // The tracked fingers' events are lost entirely; a fresh finger lands. + // Neither tracked identifier is still down, so the stuck pinch must be + // abandoned and the preview restored. + fireTouch(view, "touchstart", [ + { clientX: 40, clientY: 40, identifier: 3 }, + ]); + + expect(view.dom.style.fontSize).toBe("12px"); + expect(settingsMock.update).not.toHaveBeenCalled(); + + // A new two-finger gesture must work again. + fireTouch(view, "touchstart", [ + { clientX: 40, clientY: 40, identifier: 3 }, + { clientX: 140, clientY: 40, identifier: 4 }, + ]); + + now += 60; + fireTouch(view, "touchmove", [ + { clientX: 40, clientY: 40, identifier: 3 }, + { clientX: 340, clientY: 40, identifier: 4 }, + ]); + + now += 121; + fireTouch(view, "touchmove", [ + { clientX: 40, clientY: 40, identifier: 3 }, + { clientX: 340, clientY: 40, identifier: 4 }, + ]); + + expect(view.dom.style.fontSize).toBe("36px"); + + fireTouch(view, "touchend", [{ clientX: 40, clientY: 40, identifier: 3 }]); + + expect(settingsMock.update).toHaveBeenCalledTimes(1); + expect(settingsMock.value.fontSize).toBe("36px"); + + view.destroy(); + }); +}); diff --git a/tests/unit/quickToolsAdapterUi.test.js b/tests/unit/quickToolsAdapterUi.test.js index 6a2d1450c..e8d11846a 100644 --- a/tests/unit/quickToolsAdapterUi.test.js +++ b/tests/unit/quickToolsAdapterUi.test.js @@ -1,4 +1,9 @@ // @vitest-environment happy-dom + +import { + clampEditorFontSize, + DEFAULT_EDITOR_FONT_SIZE, +} from "cm/editorFontSize"; import { afterEach, expect, it, vi } from "vitest"; import { loadSourceModule } from "../helpers/loadSourceModule"; @@ -147,6 +152,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": {