Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/cm/mainEditorExtensions.ts
Original file line number Diff line number Diff line change
@@ -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[];
Expand Down Expand Up @@ -47,6 +48,7 @@ export function createMainEditorExtensions(
pushExtension(extensions, options.commandKeymapExtension);
pushExtension(extensions, options.themeExtension);
extensions.push(fixedHeightTheme);
extensions.push(pinchZoom());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: pinchZoom() calls ViewPlugin.define on every createMainEditorExtensions call (each file open / state recreate), producing a new plugin identity each time. A single module-level ViewPlugin constant (like searchMatchHighlighter below) would be cheaper and lets view.plugin(...) find it.

pushExtension(extensions, options.pointerCursorVisibilityExtension);
pushExtension(extensions, options.shiftClickSelectionExtension);
pushExtension(extensions, options.multiCursorSelectionExtension);
Expand Down
203 changes: 203 additions & 0 deletions src/cm/pinchZoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { ViewPlugin } from "@codemirror/view";
import settings from "lib/settings";

const ZOOM_THROTTLE_MS = 50;
const MIN_FONT_SIZE = 6;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: these limits (and the px-parsing fallback) duplicate adjustFontSize in src/cm/commandRegistry.js (Math.min(72, Math.max(6, …)), parseInt(..., 10) || 12). Exporting the constants/clamp helper from one place and reusing it there would keep pinch and the font-size commands from drifting.

const MAX_FONT_SIZE = 72;
const DEFAULT_FONT_SIZE = 12;

interface PinchPoint {
clientX: number;
clientY: number;
}

export function clampEditorFontSize(px: number): number {
if (!Number.isFinite(px)) return DEFAULT_FONT_SIZE;
return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(px)));
}

/**
* Convert a pinch distance ratio into an absolute font size.
* @param startPx font size in pixels when the gesture started
* @param startDistance distance between the two touches at the start
* @param distance current distance between the two touches
*/
export function computePinchFontSize(
startPx: number,
startDistance: number,
distance: number,
): number {
if (!Number.isFinite(startPx) || startPx <= 0) {
startPx = DEFAULT_FONT_SIZE;
}

if (
!Number.isFinite(startDistance) ||
startDistance <= 0 ||
!Number.isFinite(distance) ||
distance <= 0
) {
return clampEditorFontSize(startPx);
}

return clampEditorFontSize((startPx * distance) / startDistance);
}

function touchDistance(a: PinchPoint, b: PinchPoint): number {
return Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
}

/**
* Read the font size the editor is actually displaying, in pixels.
*
* The font theme only sets font-size on the editor root, and settings.json may
* use any unit accepted by config.FONT_SIZE (px|rem|em|pt|mm|pc|in), so the computed
* style of the editor root is the only reliable base for the pinch
* ratio. The saved setting is used as a fallback only when no computed style is
* available (e.g. in tests).
*/
function readFontSizePx(view: { dom: HTMLElement }): number {
const computed = Number.parseFloat(
getComputedStyle(view.dom).fontSize,
);
if (Number.isFinite(computed) && computed > 0) return computed;

const match = String(settings?.value?.fontSize || `${DEFAULT_FONT_SIZE}px`)
.trim()
.match(/^(\d+(?:\.\d+)?)px$/i);

if (match) {
const value = Number.parseFloat(match[1]);
if (Number.isFinite(value) && value > 0) return value;
}

return DEFAULT_FONT_SIZE;
}

export default function pinchZoom() {
return ViewPlugin.define((view) => {
const gesture = {
pinching: false,
moved: false,
startDistance: 0,
startPx: DEFAULT_FONT_SIZE,
lastPx: DEFAULT_FONT_SIZE,
pendingDistance: 0,
lastUpdate: 0,
originalInlineFontSize: "",
};

function applyFontSize(px: number) {
px = clampEditorFontSize(px);
if (px === gesture.lastPx) return;

gesture.lastPx = px;

// Preview directly on this editor. settings.update() only notifies
// update:fontSize listeners while the value differs from the last SAVED
// value, so writing settings mid-gesture can never undo a preview once
// the pinch returns to its starting size, and would leave the font
// theme stale as soon as the inline preview is removed.
view.dom.style.fontSize = `${px}px`;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing view.dom.style.fontSize outside CM's update cycle relies on the contentDOM ResizeObserver to re-measure, which skips onResize if the doc view updated in the last 75ms. If a selection/LSP update lands mid-pinch, cursor/selection layers and gutters can stay at stale geometry. Calling view.requestMeasure() after applying the inline size would make this deterministic.

}

function persistFontSize() {
if (gesture.lastPx !== gesture.startPx) {
// The value differs from the saved one, so this fires update:fontSize
// (rebuilding the font theme in every pane) and writes settings.json
// exactly once per gesture, before the inline preview is dropped below.
settings.value.fontSize = `${gesture.lastPx}px`;
settings.update(false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

settings.update() returns a promise (it awaits the settings.json write) that's neither awaited nor caught. If the write fails, it becomes an unhandled rejection; the editor already shows the new size but it silently reverts next launch. A .catch(...) would help.

}

// Let the settings-driven font theme control the editor after the gesture.
view.dom.style.fontSize = gesture.originalInlineFontSize;
}

function onTouchStart(event: TouchEvent) {
if (gesture.pinching || event.touches.length < 2) return;

event.preventDefault();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the second finger's touchstart gets preventDefault. The first finger's touchstart/pointer events still reach CodeMirror and touchSelectionMenu's capture-phase pointer handlers:

  • If the first finger moves slightly before the second lands, native scroll has started and subsequent touchmoves are cancelable=false → the editor scrolls and zooms.
  • With the quick-tools Shift/Ctrl modifier active, #onGlobalPointerDown captures a selection session for the second pointer; if it lifts within TAP_MAX_DISTANCE (common when only one finger moves), #commitPointerSelection extends/adds a selection.

Might be worth suppressing/cancelling those when a pinch starts.


gesture.pinching = true;
gesture.moved = false;
gesture.startDistance = touchDistance(
event.touches[0],
event.touches[1],
);
gesture.startPx = readFontSizePx(view);
gesture.lastPx = gesture.startPx;
gesture.pendingDistance = gesture.startDistance;
gesture.lastUpdate = 0;
gesture.originalInlineFontSize = view.dom.style.fontSize;
}

function onTouchMove(event: TouchEvent) {
if (!gesture.pinching || event.touches.length < 2) return;

event.preventDefault();

// The gesture received a real movement; remember the latest distance
// even when the preview itself is throttled.
gesture.moved = true;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved is set on any touchmove, including sub-pixel finger jitter. On a real device a two-finger tap almost always emits at least one touchmove, so the guard in endPinch that's meant to protect taps (9.5px → 10px, 99px → 72px) basically never applies. The tests only cover the no-touchmove case. Could we set moved only once the distance changes past a small threshold (a few px)?

gesture.pendingDistance = touchDistance(
event.touches[0],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using touches[0]/touches[1] without tracking Touch.identifier means the finger pair can change mid-gesture: pinch with A+B, a third finger C lands (ignored since pinching), A lifts → touches.length === 2 so endPinch returns early, and the next move measures B–C against the A–B startDistance → font size jumps (and can be persisted). Suggest storing both identifiers on touchstart, looking them up on move, and ending the pinch when either disappears.

event.touches[1],
);

const now = Date.now();
if (now - gesture.lastUpdate < ZOOM_THROTTLE_MS) return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leading-edge throttle with no trailing flush: if the fingers stop within 50ms of the last applied frame, the preview stays stale until touchend, then jumps to a size the user never previewed before it's saved. Coalescing via requestAnimationFrame (store pendingDistance, schedule one rAF, cancel in destroy) would be smoother and simpler.


gesture.lastUpdate = now;

applyFontSize(
computePinchFontSize(
gesture.startPx,
gesture.startDistance,
gesture.pendingDistance,
),
);
}

function endPinch(event: TouchEvent) {
if (!gesture.pinching || event.touches.length >= 2) return;

// Only flush the latest movement when the gesture actually moved:
// a two-finger tap must not round or clamp the saved size
// (9.5px -> 10px, 99px -> 72px).
if (gesture.moved) {
applyFontSize(
computePinchFontSize(
gesture.startPx,
gesture.startDistance,
gesture.pendingDistance,
),
);
}

gesture.pinching = false;
persistFontSize();
}

const { dom } = view;

dom.addEventListener("touchstart", onTouchStart, { passive: false });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Listeners are on view.dom, which also contains the search panel, autocomplete/hover tooltips, and gutters. A two-finger gesture on any of those zooms the editor and preventDefault blocks two-finger scrolling inside scrollable tooltips/panels. Consider attaching to view.scrollDOM/contentDOM, or skipping targets inside .cm-panels / .cm-tooltip.

dom.addEventListener("touchmove", onTouchMove, { passive: false });
dom.addEventListener("touchend", endPinch);
dom.addEventListener("touchcancel", endPinch);

return {
destroy() {
// Never leave a half-finished preview behind.
if (gesture.pinching) {
view.dom.style.fontSize = gesture.originalInlineFontSize;
gesture.pinching = false;
}
dom.removeEventListener("touchstart", onTouchStart);
dom.removeEventListener("touchmove", onTouchMove);
dom.removeEventListener("touchend", endPinch);
dom.removeEventListener("touchcancel", endPinch);
},
};
});
}
Loading
Loading