feat(editor): snap element bitmaps, scroll and grid to whole device pixels (#12063)

* feat(editor): blit cached element bitmaps on whole device pixels

Elements are painted from cached bitmaps, blitted 1:1 with smoothing
disabled for unrotated and right-angle elements. Nearest-neighbor at a
fractional offset is then a pixel-exact copy shifted to the nearest
pixel — except at an exact half pixel, where a GPU-accelerated canvas
decides the rounding per scanline by float precision: a few rows sample
the neighboring row and glyph strokes come out doubled or broken, varying
with scroll and position. A centered label lands on a half pixel
routinely (odd free space ÷ 2), which is how it showed up on sticky notes.

Measured in Chrome: a label blitted at y = 639.5 had 3 of 82 scanlines
taken from the adjacent row; rounded to 640 it was clean.

Round the blit position to whole device pixels for those elements. The
context is scaled at the blit (canvas scale × zoom ÷ devicePixelRatio),
so the rounding goes through `context.getTransform()`: rounding the
pre-zoom value only lands on device pixels at 100% zoom. Rotated
elements are resampled regardless and are left alone; export draws
directly and is unaffected.

Tests: the static canvas's `drawImage` events, with their CTM, land on
whole device pixels for text and a rectangle at zoom 1, 1.5, 0.73 and
2.2 under fractional scroll; a rotated element keeps its offset.

Extracted from dwelle/sticky-notes-2 (originally eddb67ca0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): draw the canvases at a whole-device-pixel scroll

Panning writes a fractional scroll (pointer delta ÷ zoom). With every
bitmap landing on its nearest device pixel, a fractional scroll means
elements with different sub-pixel phases jump on different frames — a
one-pixel wobble between neighbors, a label shifting against its box —
and one scroll value in every pixel puts every integer-positioned
element on the half-pixel boundary at once.

Round the scroll to whole device pixels at render time, in all three
renderers with the same formula so overlays sit on the content — the way
a browser scrolls a page: each element keeps its own constant sub-pixel
phase and the scene moves rigidly. The real scroll in state is untouched,
so hit-testing and DOM overlays are unaffected; the difference stays
under half a device pixel. Export is excluded (it draws vectors).

Tests: the helper rounds to device pixels and is identity when already
whole; a rectangle at integer coordinates blits on whole device pixels at
zoom 1 and 1.5 under fractional scroll, and a scroll a tenth of a pixel
further lands on the same pixels.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): draw grid lines on whole device pixels at every zoom

The grid was nudged onto the pixel grid only at 100% zoom, and only when
the scroll happened to be whole; at any other zoom its one-pixel lines
landed on fractional device pixels and rendered as two lighter pixels.

Draw each line a whole number of device pixels wide, centered to cover
them exactly (on the half pixel when the count is odd), computed from the
zoom and the canvas scale — which also makes "one pixel" one device pixel
group at devicePixelRatio 2 instead of a fractional pair. Lines thinner
than a device pixel (zoomed far out) keep their sub-pixel width: their
lightness is the point. The zoom-1-only nudge is subsumed.

Test: with grid mode on, every grid path start on the static canvas has
its across-axis coordinate on a half device pixel at zoom 1, 1.5 and 2.2
under fractional scroll.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): snap right-angle blits via the transform origin; grid width in device pixels

Two review findings.

Elements rotated by 90° or 270° enter the snapping branch (smoothing is
off for right angles too), but their transform has near-zero `a` and `d`
with the scale in `b`/`c`. Dividing by the diagonal sent the bitmap far
offscreen — a rectangle at (110.37, 120.61) vanished at both angles.
The snap now moves the transform's origin onto the rounded device
position of the blit and draws at (0, 0): rotation, mirroring and scale
stay in `a`–`d`, no inverse is needed, and the origin is an exact integer
even in the canvas's float32 matrix (mapping a rounded position back
through user space left it ~1e-5 off).

The grid's "at least one device pixel wide" test compared
`(1 / zoom) × zoom × scale` against 1 exactly; at 190% that product is
0.9999999999999999, so those lines skipped snapping and stayed blurry
while neighboring zooms were crisp. The width is now computed straight
in device pixels, `min(scale, max × zoom × scale)`, so there is no round
trip to tolerate.

Tests: rectangles at π/2 and 3π/2 blit on whole device pixels and stay
next to the element; the grid test includes 190%. The blit helper in the
test maps positions through the full matrix. The CHANGELOG bullets link
the PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(editor): one devicePixelRatio read for the blit, no-op padding scaling dropped

`drawElementFromCanvas` read `window.devicePixelRatio` eleven times; it
reads it once. That read stays the global one on purpose: the cached
bitmap was generated with it (`generateElementCanvas`) and the blit's
size math needs the same value, so switching this path to the owner
window's ratio is a separate change that has to include bitmap
generation. `(padding × scale) / scale` is `padding`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): snap blits only where smoothing is off, through one predicate

The snap ran during zoom gestures too, where the cached bitmaps stay at
the old scale and are resampled with smoothing on: rounding there could
only make elements twitch as the scale changes.

The smoothing switch and the snap now share `canSnapElement` — unrotated
or at a right angle, and not mid-gesture — so the two cannot drift
apart. It is eligibility, not a guarantee of a 1:1 copy: freedraw is
blitted with smoothing on and a size-capped cache is rescaled; snapping
is harmless for both.

Test: with `shouldCacheIgnoreZoom` set, the blit keeps its fractional
position.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): snap a label relative to its container, tie-safe

Each bitmap rounding its own origin lets a label shift by a pixel against
its box as a drag crosses half pixels (nearest-neighbor sampling did the
same implicitly before the explicit snap). A label under an unrotated
container now anchors to the container's rounded origin and rounds its
offset from it separately, so both land on whole pixels and their
distance never changes; every other element anchors to itself with a
zero offset, through the same code path.

The offset is formed from scene coordinates before the scroll is added,
and a tiny bias breaks an exact half-pixel tie the same way every frame.
Ties are common — any odd scene offset at 150% — and the float noise of
a fractional drag otherwise flips `Math.round` between the two neighbors:
a 200-wide box with a centered label went 92 → 91 → 92 device pixels
across drags of 0, 0.3 and 0.4.

Tests: a plain fractional offset and the half-pixel tie keep a constant
device offset across five fractional drags at 150%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David Luzar
2026-09-09 14:39:59 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 854d00c31b
commit 6574de60b3
6 changed files with 478 additions and 48 deletions
+113 -29
View File
@@ -583,6 +583,38 @@ const generateElementWithCanvas = (
return prevElementWithCanvas;
};
/**
* Whether an element's cached bitmap may be laid on the device-pixel grid:
* unrotated or at a right angle, and not during a zoom gesture. The cached
* path disables smoothing for exactly these — a nearest-neighbor 1:1 blit is
* a pixel-exact copy, and rounding its origin keeps it off the half-pixel
* boundary where it tears — and every eligible blit is snapped. It is
* eligibility, not a guarantee of a 1:1 copy: freedraw is blitted with
* smoothing on, and a size-capped cache is rescaled; snapping is harmless
* for both.
*
* Not during a zoom gesture: the bitmaps stay at the old scale and are
* resampled, and blurry shapes look better on low resolution (while still
* zooming in) than sharp ones; snapping would only make elements twitch.
* Right angles qualify because smoothing can be off there without aliasing
* (for other angles it is terrible on Chromium); the right-angle test
* tolerates float arithmetic.
*/
const canSnapElement = (
element: ExcalidrawElement,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
) =>
!appState?.shouldCacheIgnoreZoom &&
(!element.angle || isRightAngleRads(element.angle));
/**
* Breaks a `Math.round` tie at exactly half a device pixel the same way
* every frame. A label's offset from its container lands on one routinely
* (an odd scene offset at 150%), and the float noise of a fractional drag
* would otherwise flip it between the two neighbors.
*/
const SNAP_TIE_BIAS = 1e-6;
const drawElementFromCanvas = (
elementWithCanvas: ExcalidrawElementWithCanvas,
context: CanvasRenderingContext2D,
@@ -591,13 +623,16 @@ const drawElementFromCanvas = (
allElementsMap: NonDeletedSceneElementsMap,
) => {
const element = elementWithCanvas.element;
// the ratio the cached bitmap was generated with (`generateElementCanvas`);
// the blit's size math needs the same one, so it is not the owner window's
const devicePixelRatio = window.devicePixelRatio;
const padding = getCanvasPadding(element);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
const cx = ((x1 + x2) / 2 + appState.scrollX) * devicePixelRatio;
const cy = ((y1 + y2) / 2 + appState.scrollY) * devicePixelRatio;
context.save();
context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
context.scale(1 / devicePixelRatio, 1 / devicePixelRatio);
const boundTextElement = getBoundTextElement(element, allElementsMap);
@@ -612,7 +647,7 @@ const drawElementFromCanvas = (
);
// generously covers the arrow's blit at any rotation
const outerHalf =
Math.max(distance(x1, x2), distance(y1, y2)) * window.devicePixelRatio +
Math.max(distance(x1, x2), distance(y1, y2)) * devicePixelRatio +
padding * 10;
context.beginPath();
context.rect(cx - outerHalf, cy - outerHalf, outerHalf * 2, outerHalf * 2);
@@ -621,16 +656,14 @@ const drawElementFromCanvas = (
boundTextElement.width / 2 -
BOUND_TEXT_PADDING +
appState.scrollX) *
window.devicePixelRatio,
devicePixelRatio,
(boundTextCy -
boundTextElement.height / 2 -
BOUND_TEXT_PADDING +
appState.scrollY) *
window.devicePixelRatio,
(boundTextElement.width + BOUND_TEXT_PADDING * 2) *
window.devicePixelRatio,
(boundTextElement.height + BOUND_TEXT_PADDING * 2) *
window.devicePixelRatio,
devicePixelRatio,
(boundTextElement.width + BOUND_TEXT_PADDING * 2) * devicePixelRatio,
(boundTextElement.height + BOUND_TEXT_PADDING * 2) * devicePixelRatio,
);
context.clip("evenodd");
}
@@ -654,16 +687,75 @@ const drawElementFromCanvas = (
// revert afterwards we don't have account for it during drawing
context.translate(-cx, -cy);
// the blit origin, in the space the context is in here (scaled by
// canvas scale × zoom ÷ devicePixelRatio)
let drawX = (x1 + appState.scrollX) * devicePixelRatio - padding;
let drawY = (y1 + appState.scrollY) * devicePixelRatio - padding;
const transform = context.getTransform();
if (canSnapElement(element, appState)) {
// blit the cached bitmap on whole device pixels. Nearest-neighbor at a
// fractional offset is a pixel-exact copy shifted to the nearest pixel
// — except at an exact half pixel, where a GPU-accelerated canvas
// decides the rounding per scanline by float precision and a few rows
// sample the neighboring row: doubled or broken strokes, varying with
// scroll and position. A centered label lands on a half pixel
// routinely.
//
// Done by moving the transform's origin onto the rounded device
// position of the blit and drawing at (0, 0): the rotation, mirroring
// and scale stay in `a`–`d` (a right angle keeps its scale in `b`/`c`),
// and the origin is an exact integer even in the canvas's float32
// matrix. Rounding `drawX` itself only lands on device pixels at 100%
// zoom.
const { a, b, c, d, e, f } = transform;
const container = isTextElement(element)
? getContainerElement(element, allElementsMap)
: null;
// A bound label shares its unrotated container's anchor. Other bitmaps
// anchor to themselves, with a zero relative offset.
const anchor = container && !container.angle ? container : element;
const anchorCoords =
anchor === element
? null
: getElementAbsoluteCoords(anchor, allElementsMap);
const anchorSceneX = anchorCoords?.[0] ?? x1;
const anchorSceneY = anchorCoords?.[1] ?? y1;
const anchorPadding =
anchor === element ? padding : getCanvasPadding(anchor);
const anchorX =
(anchorSceneX + appState.scrollX) * devicePixelRatio - anchorPadding;
const anchorY =
(anchorSceneY + appState.scrollY) * devicePixelRatio - anchorPadding;
// Form the relative vector before introducing the scroll translation.
const dx = (x1 - anchorSceneX) * devicePixelRatio + anchorPadding - padding;
const dy = (y1 - anchorSceneY) * devicePixelRatio + anchorPadding - padding;
context.setTransform(
a,
b,
c,
d,
Math.round(a * anchorX + c * anchorY + e) +
Math.round(a * dx + c * dy + SNAP_TIE_BIAS),
Math.round(b * anchorX + d * anchorY + f) +
Math.round(b * dx + d * dy + SNAP_TIE_BIAS),
);
drawX = 0;
drawY = 0;
}
context.drawImage(
elementWithCanvas.canvas!,
(x1 + appState.scrollX) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
(y1 + appState.scrollY) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
drawX,
drawY,
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
);
context.setTransform(transform);
if (
import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX ===
"true" &&
@@ -677,10 +769,10 @@ const drawElementFromCanvas = (
context.strokeStyle = "#c92a2a";
context.lineWidth = 3;
context.strokeRect(
(coords.x + appState.scrollX) * window.devicePixelRatio,
(coords.y + appState.scrollY) * window.devicePixelRatio,
getBoundTextMaxWidth(element, textElement) * window.devicePixelRatio,
getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
(coords.x + appState.scrollX) * devicePixelRatio,
(coords.y + appState.scrollY) * devicePixelRatio,
getBoundTextMaxWidth(element, textElement) * devicePixelRatio,
getBoundTextMaxHeight(element, textElement) * devicePixelRatio,
);
}
context.restore();
@@ -921,17 +1013,9 @@ export const renderElement = (
const currentImageSmoothingStatus = context.imageSmoothingEnabled;
if (
// do not disable smoothing during zoom as blurry shapes look better
// on low resolution (while still zooming in) than sharp ones
!appState?.shouldCacheIgnoreZoom &&
// angle is 0 -> always disable smoothing
(!element.angle ||
// or check if angle is a right angle in which case we can still
// disable smoothing without adversely affecting the result
// We need less-than comparison because of FP artihmetic
isRightAngleRads(element.angle))
) {
// see `canSnapElement` for why not during zoom gestures or at
// other angles
if (canSnapElement(element, appState)) {
// Disabling smoothing makes output much sharper, especially for
// text. Unless for non-right angles, where the aliasing is really
// terrible on Chromium.
+29
View File
@@ -33,6 +33,35 @@ export const fillCircle = (
}
};
/**
* The scroll the renderers draw at: the real one, rounded to whole device
* pixels. Panning writes a fractional scroll (pointer delta ÷ zoom), and an
* element's cached bitmap blitted at a fractional device offset gets
* resampled — so as the fraction drifts under a pan, every element pulses
* between crisp and soft. A whole-pixel scroll moves the scene the way a
* browser scrolls a page: each element keeps its own, constant sub-pixel
* phase and a pan never changes how anything is filtered. The difference
* stays under half a device pixel, so hit-testing and DOM overlays keep
* using the real scroll. Identity when nothing needs to change.
*/
export const snapScrollToDevicePixels = <
T extends { scrollX: number; scrollY: number; zoom: { value: number } },
>(
appState: T,
scale: number,
): T => {
// device pixels per scene unit
const devicePixels = appState.zoom.value * scale;
if (!(devicePixels > 0)) {
return appState;
}
const scrollX = Math.round(appState.scrollX * devicePixels) / devicePixels;
const scrollY = Math.round(appState.scrollY * devicePixels) / devicePixels;
return scrollX === appState.scrollX && scrollY === appState.scrollY
? appState
: { ...appState, scrollX, scrollY };
};
export const getNormalizedCanvasDimensions = (
canvas: HTMLCanvasElement,
scale: number,
@@ -99,6 +99,7 @@ import {
fillCircle,
getNormalizedCanvasDimensions,
strokeRectWithRotation_simple,
snapScrollToDevicePixels,
} from "./helpers";
import type {
@@ -1631,7 +1632,7 @@ const _renderInteractiveScene = ({
selectedElements,
allElementsMap,
scale,
appState,
appState: unsnappedAppState,
renderConfig,
editorInterface,
animationState,
@@ -1644,6 +1645,10 @@ const _renderInteractiveScene = ({
return {};
}
// the same whole-device-pixel scroll the static scene draws at, so the
// overlays sit exactly on the content
const appState = snapScrollToDevicePixels(unsnappedAppState, scale);
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
scale,
@@ -7,7 +7,11 @@ import {
shouldApplyFrameClip,
} from "@excalidraw/element";
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
import {
bootstrapCanvas,
getNormalizedCanvasDimensions,
snapScrollToDevicePixels,
} from "./helpers";
import { frameClip } from "./staticScene";
@@ -20,10 +24,12 @@ const _renderNewElementScene = ({
elementsMap,
allElementsMap,
scale,
appState,
appState: unsnappedAppState,
renderConfig,
}: NewElementSceneRenderConfig) => {
if (canvas) {
// the same whole-device-pixel scroll the static scene draws at
const appState = snapScrollToDevicePixels(unsnappedAppState, scale);
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
scale,
+46 -16
View File
@@ -35,7 +35,11 @@ import {
getLinkHandleFromCoords,
} from "../components/hyperlink/helpers";
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
import {
bootstrapCanvas,
getNormalizedCanvasDimensions,
snapScrollToDevicePixels,
} from "./helpers";
import type {
StaticCanvasRenderConfig,
@@ -66,6 +70,7 @@ const strokeGrid = (
theme: StaticCanvasRenderConfig["theme"],
width: number,
height: number,
scale: number,
) => {
const offsetX = (scrollX % gridSize) - gridSize;
const offsetY = (scrollY % gridSize) - gridSize;
@@ -74,15 +79,35 @@ const strokeGrid = (
const spaceWidth = 1 / zoom.value;
context.save();
// scene units → device pixels
const devicePixels = zoom.value * scale;
// Offset rendering by 0.5 to ensure that 1px wide lines are crisp.
// We only do this when zoomed to 100% because otherwise the offset is
// fractional, and also visibly offsets the elements.
// We also do this per-axis, as each axis may already be offset by 0.5.
if (zoom.value === 1) {
context.translate(offsetX % 1 ? 0 : 0.5, offsetY % 1 ? 0 : 0.5);
}
// A line at least a device pixel wide is drawn a whole number of device
// pixels wide and centered to cover them exactly — on the half pixel when
// the count is odd. Straddling a pixel boundary renders it as two lighter
// pixels, which is how the grid looked at every zoom but 100%. Thinner
// lines (zoomed far out) keep their sub-pixel width: their lightness is
// the point.
const snap = (position: number, maxWidthInCssPixels: number) => {
// a line is `min(1 / zoom, max)` scene units wide; computed straight in
// device pixels, so `(1 / zoom) × zoom` never lands just under 1
const widthInDevicePixels = Math.min(
scale,
maxWidthInCssPixels * devicePixels,
);
if (widthInDevicePixels < 1) {
return { position, lineWidth: widthInDevicePixels / devicePixels };
}
const wholeWidth = Math.round(widthInDevicePixels);
const center = wholeWidth % 2 ? 0.5 : 0;
return {
position:
(Math.round(position * devicePixels - center) + center) / devicePixels,
lineWidth: wholeWidth / devicePixels,
};
};
context.save();
// vertical lines
for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) {
@@ -93,7 +118,7 @@ const strokeGrid = (
continue;
}
const lineWidth = Math.min(1 / zoom.value, isBold ? 4 : 1);
const { position, lineWidth } = snap(x, isBold ? 4 : 1);
context.lineWidth = lineWidth;
const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
@@ -102,8 +127,8 @@ const strokeGrid = (
context.strokeStyle = isBold
? GridLineColor[theme].bold
: GridLineColor[theme].regular;
context.moveTo(x, offsetY - gridSize);
context.lineTo(x, Math.ceil(offsetY + height + gridSize * 2));
context.moveTo(position, offsetY - gridSize);
context.lineTo(position, Math.ceil(offsetY + height + gridSize * 2));
context.stroke();
}
@@ -114,7 +139,7 @@ const strokeGrid = (
continue;
}
const lineWidth = Math.min(1 / zoom.value, isBold ? 4 : 1);
const { position, lineWidth } = snap(y, isBold ? 4 : 1);
context.lineWidth = lineWidth;
const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
@@ -123,8 +148,8 @@ const strokeGrid = (
context.strokeStyle = isBold
? GridLineColor[theme].bold
: GridLineColor[theme].regular;
context.moveTo(offsetX - gridSize, y);
context.lineTo(Math.ceil(offsetX + width + gridSize * 2), y);
context.moveTo(offsetX - gridSize, position);
context.lineTo(Math.ceil(offsetX + width + gridSize * 2), position);
context.stroke();
}
context.restore();
@@ -240,7 +265,7 @@ const _renderStaticScene = ({
allElementsMap,
visibleElements,
scale,
appState,
appState: unsnappedAppState,
renderConfig,
}: StaticSceneRenderConfig) => {
if (canvas === null) {
@@ -248,6 +273,10 @@ const _renderStaticScene = ({
}
const { renderGrid = true, isExporting } = renderConfig;
// export draws vectors, not cached bitmaps — nothing to keep on the grid
const appState = isExporting
? unsnappedAppState
: snapScrollToDevicePixels(unsnappedAppState, scale);
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
@@ -279,6 +308,7 @@ const _renderStaticScene = ({
renderConfig.theme,
normalizedWidth / appState.zoom.value,
normalizedHeight / appState.zoom.value,
scale,
);
}
@@ -0,0 +1,276 @@
import React from "react";
import { Excalidraw } from "../index";
import { snapScrollToDevicePixels } from "../renderer/helpers";
import { API } from "./helpers/api";
import { GlobalTestState, render, waitFor } from "./test-utils";
import type { NormalizedZoomValue } from "../types";
type CanvasEvent = {
type: string;
transform: [number, number, number, number, number, number];
props: { dx: number; dy: number; dWidth: number };
};
/** the static canvas's element blits, in device pixels */
const getBlits = () => {
const context = GlobalTestState.canvas.getContext("2d") as any;
return (context.__getEvents() as CanvasEvent[])
.filter((event) => event.type === "drawImage")
.map(({ transform: [a, b, c, d, e, f], props }) => ({
// the matrix's scale, whatever its rotation
scale: Math.hypot(a, b),
x: a * props.dx + c * props.dy + e,
y: b * props.dx + d * props.dy + f,
width: props.dWidth,
}));
};
/** re-renders at the given viewport and returns that render's blits */
const renderAt = async (zoom: number, scrollX: number, scrollY: number) => {
(GlobalTestState.canvas.getContext("2d") as any).__clearEvents();
API.setAppState({
zoom: { value: zoom as NormalizedZoomValue },
scrollX,
scrollY,
});
return waitFor(() => {
// the static scene renders on the next frame
const blits = getBlits().filter(
(blit) => Math.abs(blit.scale - zoom) < 1e-9,
);
expect(blits.length).toBeGreaterThan(0);
return blits;
});
};
const distanceToWholePixel = ({ x, y }: { x: number; y: number }) =>
Math.abs(x - Math.round(x)) + Math.abs(y - Math.round(y));
describe("element pixel snap", () => {
beforeEach(async () => {
await render(<Excalidraw />);
// jsdom gives the editor no size, which culls every element
API.setAppState({ width: 800, height: 600 });
});
it.each([
["text", { type: "text" as const, text: "hello" }],
["rectangle", { type: "rectangle" as const, width: 120, height: 60 }],
])(
"blits an unrotated %s on whole device pixels at any zoom",
async (_, props) => {
API.setElements([API.createElement({ x: 10.37, y: 20.61, ...props })]);
for (const zoom of [1, 1.5, 0.73, 2.2]) {
const blits = await renderAt(zoom, 3.3, -7.77);
for (const blit of blits) {
expect(distanceToWholePixel(blit)).toBeLessThan(1e-6);
}
}
},
);
it.each([Math.PI / 2, (3 * Math.PI) / 2])(
"snaps a right-angle rotation (%s rad) without displacing it",
async (angle) => {
API.setElements([
API.createElement({
type: "rectangle",
x: 110.37,
y: 120.61,
width: 120,
height: 60,
angle: angle as any,
}),
]);
const blits = await renderAt(1.5, 3.3, -7.77);
for (const blit of blits) {
expect(distanceToWholePixel(blit)).toBeLessThan(1e-6);
// and it stays where the element is — a formula that divides by the
// near-zero diagonal of a rotation sends it far offscreen
expect(Math.abs(blit.x)).toBeLessThan(2000);
expect(Math.abs(blit.y)).toBeLessThan(2000);
}
},
);
it("does not snap while a zoom gesture keeps the cached bitmaps", async () => {
API.setElements([
API.createElement({ type: "text", x: 10.37, y: 20.61, text: "hello" }),
]);
// the bitmaps are resampled during the gesture; rounding would only
// make elements twitch
await renderAt(1, 0, 0);
(GlobalTestState.canvas.getContext("2d") as any).__clearEvents();
API.setAppState({
shouldCacheIgnoreZoom: true,
zoom: { value: 1.5 as NormalizedZoomValue },
scrollX: 3.3,
scrollY: -7.77,
});
const [blit] = await waitFor(() => {
const blits = getBlits();
expect(blits.length).toBeGreaterThan(0);
return blits;
});
expect(distanceToWholePixel(blit)).toBeGreaterThan(1e-3);
API.setAppState({ shouldCacheIgnoreZoom: false });
});
it.each([
// a plain fractional offset, and one that is exactly half a device pixel
// at 150% (51 scene units → 76.5), where the float noise of a drag used
// to flip the rounding between two neighbors
["fractional", { box: [100.3, 100.3], label: [150.7, 140.2] }],
["half-pixel tie", { box: [10, 10], label: [61, 30] }],
])(
"keeps a label at a constant device offset from its container while dragged (%s)",
async (_, { box, label }) => {
const offsets = new Set<string>();
for (const drag of [0, 0.3, 0.4, 0.7, 1.4]) {
API.setElements([
API.createElement({
type: "rectangle",
id: "box",
x: box[0] + drag,
y: box[1] + drag,
width: 200,
height: 100,
boundElements: [{ type: "text", id: "label" }],
}),
API.createElement({
type: "text",
id: "label",
x: label[0] + drag,
y: label[1] + drag,
text: "hi",
fontSize: 20,
containerId: "box",
}),
]);
const blits = await renderAt(1.5, 3.3, -7.77);
expect(blits).toHaveLength(2);
for (const blit of blits) {
expect(distanceToWholePixel(blit)).toBeLessThan(1e-6);
}
// the container's bitmap is the wider one
const [labelBlit, boxBlit] = [...blits].sort(
(p, q) => p.width - q.width,
);
offsets.add(`${labelBlit.x - boxBlit.x},${labelBlit.y - boxBlit.y}`);
}
expect(offsets.size).toBe(1);
},
);
it("leaves rotated elements alone", async () => {
API.setElements([
API.createElement({
type: "text",
x: 10.37,
y: 20.61,
text: "hello",
angle: 0.4 as any,
}),
]);
(GlobalTestState.canvas.getContext("2d") as any).__clearEvents();
API.setAppState({
zoom: { value: 1.5 as NormalizedZoomValue },
scrollX: 3.3,
scrollY: -7.77,
});
// a rotated blit is resampled regardless; at these coordinates its
// unsnapped offset is not a whole pixel
const [blit] = await waitFor(() => {
const blits = getBlits();
expect(blits.length).toBeGreaterThan(0);
return blits;
});
expect(distanceToWholePixel(blit)).toBeGreaterThan(1e-3);
});
});
describe("scroll pixel snap", () => {
it("rounds the scroll to whole device pixels and is identity when it already is", () => {
const zoom = { value: 1.5 as NormalizedZoomValue };
const snapped = snapScrollToDevicePixels(
{ scrollX: 3.3, scrollY: -7.77, zoom },
2,
);
// 3.3 × 1.5 × 2 = 9.9 → 10; -7.77 × 3 = -23.31 → -23
expect(snapped.scrollX * 3).toBeCloseTo(10, 9);
expect(snapped.scrollY * 3).toBeCloseTo(-23, 9);
const whole = { scrollX: 4, scrollY: -6, zoom };
expect(snapScrollToDevicePixels(whole, 2)).toBe(whole);
});
it("blits a shape at integer coordinates on whole device pixels at any fractional scroll", async () => {
await render(<Excalidraw />);
API.setAppState({ width: 800, height: 600 });
API.setElements([
API.createElement({
type: "rectangle",
x: 100,
y: 200,
width: 200,
height: 100,
}),
]);
// zooms at which the element canvas's padding is itself whole device
// pixels, so the only fractional term left is the scroll
for (const zoom of [1, 1.5]) {
const [atFirst] = await renderAt(zoom, 3.3, -7.77);
expect(distanceToWholePixel(atFirst)).toBeLessThan(1e-6);
// a tenth of a device pixel further lands on the same pixels
const [atSecond] = await renderAt(zoom, 3.3 + 0.1 / zoom, -7.77);
expect(atSecond).toEqual(atFirst);
}
});
});
describe("grid pixel snap", () => {
/** the static canvas's path starts, in device pixels */
const getPathStarts = () => {
const context = GlobalTestState.canvas.getContext("2d") as any;
return (context.__getEvents() as CanvasEvent[])
.filter((event) => event.type === "moveTo")
.map(({ transform: [a, , , d, e, f], props }) => ({
scale: a,
x: a * (props as any).x + e,
y: d * (props as any).y + f,
}));
};
const fraction = (value: number) => value - Math.floor(value);
it("centers one-pixel grid lines on device pixels at any zoom", async () => {
await render(<Excalidraw />);
API.setAppState({ width: 800, height: 600, gridModeEnabled: true });
// zooms at which a grid line is at least a device pixel wide — 1.9
// included, where `(1 / zoom) × zoom` evaluates just under 1
for (const zoom of [1, 1.5, 1.9, 2.2]) {
(GlobalTestState.canvas.getContext("2d") as any).__clearEvents();
API.setAppState({
zoom: { value: zoom as NormalizedZoomValue },
scrollX: 3.3,
scrollY: -7.77,
});
const lines = await waitFor(() => {
const starts = getPathStarts().filter(
(start) => Math.abs(start.scale - zoom) < 1e-9,
);
expect(starts.length).toBeGreaterThan(0);
return starts;
});
for (const line of lines) {
// a vertical line has its x on the half pixel, a horizontal its y
const onHalfPixel =
Math.abs(fraction(line.x) - 0.5) < 1e-6 ||
Math.abs(fraction(line.y) - 0.5) < 1e-6;
expect(onHalfPixel).toBe(true);
}
}
});
});