import React from "react"; import { Excalidraw } from "../index"; import { snapScrollToDevicePixels } from "../renderer/helpers"; import { API } from "./helpers/api"; import { act, 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(); // 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(); 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); }); it.each([0, Math.PI / 2, 0.4])( "places overridden bitmaps like translated document geometry (%s rad)", async (angle) => { const ownerWindow = GlobalTestState.canvas.ownerDocument.defaultView!; const pixelRatioDescriptor = Object.getOwnPropertyDescriptor( ownerWindow, "devicePixelRatio", )!; try { for (const devicePixelRatio of [1, 2]) { Object.defineProperty(ownerWindow, "devicePixelRatio", { configurable: true, value: devicePixelRatio, }); for (const zoom of [1, 1.5, 0.73]) { const props = { type: "rectangle" as const, x: 110.37, y: 140.61, width: 120, height: 60, angle: angle as any, }; const element = API.createElement(props); API.setElements([element]); await renderAt(zoom, 3.3, -7.77); (GlobalTestState.canvas.getContext("2d") as any).__clearEvents(); act(() => window.h.app.api.setElementRenderOverrides( new Map([[element.id, { offset: { x: 13.37, y: -7.61 } }]]), ), ); const [overridden] = await waitFor(() => { const blits = getBlits(); expect(blits).toHaveLength(1); return blits; }); act(() => window.h.app.api.setElementRenderOverrides(null)); API.setElements([ API.createElement({ ...props, x: props.x + 13.37, y: props.y - 7.61, }), ]); const [translated] = await renderAt(zoom, 3.3, -7.77); expect(overridden.x).toBeCloseTo(translated.x, 6); expect(overridden.y).toBeCloseTo(translated.y, 6); expect(overridden.scale).toBeCloseTo(translated.scale, 6); if (angle !== 0.4) { expect(distanceToWholePixel(overridden)).toBeLessThan(1e-6); } } } } finally { Object.defineProperty( ownerWindow, "devicePixelRatio", pixelRatioDescriptor, ); } }, ); it("keeps bound-label snapping stable through inherited fractional offsets", async () => { const box = API.createElement({ type: "rectangle", id: "box", x: 10, y: 10, width: 200, height: 100, boundElements: [{ type: "text", id: "label" }], }); const label = API.createElement({ type: "text", id: "label", x: 61, y: 30, text: "hi", fontSize: 20, containerId: box.id, }); API.setElements([box, label]); await renderAt(1.5, 3.3, -7.77); const offsets = new Set(); for (const translation of [0, 0.3, 0.4, 0.7, 1.4]) { (GlobalTestState.canvas.getContext("2d") as any).__clearEvents(); act(() => window.h.app.api.setElementRenderOverrides( new Map([[box.id, { offset: { x: translation, y: translation } }]]), ), ); const blits = await waitFor(() => { const blits = getBlits(); expect(blits).toHaveLength(2); return blits; }); 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); }); }); 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(); 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(); 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); } } }); });