mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
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:
co-authored by
Claude Fable 5.1
parent
854d00c31b
commit
6574de60b3
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user