diff --git a/packages/excalidraw/renderer/helpers.test.ts b/packages/excalidraw/renderer/helpers.test.ts new file mode 100644 index 0000000000..32376d1a41 --- /dev/null +++ b/packages/excalidraw/renderer/helpers.test.ts @@ -0,0 +1,90 @@ +import { COLOR_WHITE } from "@excalidraw/common"; + +import { bootstrapCanvas } from "./helpers"; + +const setup = () => { + const canvas = document.createElement("canvas"); + canvas.width = 200; + canvas.height = 100; + const context = canvas.getContext("2d")!; + const clearRect = vi.spyOn(context, "clearRect"); + const fillRect = vi.spyOn(context, "fillRect"); + return { canvas, context, clearRect, fillRect }; +}; + +const run = (viewBackgroundColor: unknown) => { + const { canvas, context, clearRect, fillRect } = setup(); + bootstrapCanvas({ + canvas, + scale: 1, + normalizedWidth: 200, + normalizedHeight: 100, + viewBackgroundColor: viewBackgroundColor as string, + }); + return { context, clearRect, fillRect }; +}; + +describe("bootstrapCanvas background painting", () => { + it("skips clearRect for an opaque hex color (fill fully repaints)", () => { + const { clearRect, fillRect } = run("#ffffff"); + expect(clearRect).not.toHaveBeenCalled(); + expect(fillRect).toHaveBeenCalledTimes(1); + }); + + it("skips clearRect for a 3-digit opaque hex color", () => { + const { clearRect, fillRect } = run("#fff"); + expect(clearRect).not.toHaveBeenCalled(); + expect(fillRect).toHaveBeenCalledTimes(1); + }); + + it("clears for a hex color with alpha (#RGBA / #RRGGBBAA)", () => { + expect(run("#ffff").clearRect).toHaveBeenCalledTimes(1); + expect(run("#ffffff80").clearRect).toHaveBeenCalledTimes(1); + }); + + it("clears and skips fill for the transparent keyword", () => { + const { clearRect, fillRect } = run("transparent"); + expect(clearRect).toHaveBeenCalledTimes(1); + expect(fillRect).not.toHaveBeenCalled(); + }); + + it("clears for rgba()/hsla() colors and still fills", () => { + const rgba = run("rgba(255, 0, 0, 0.5)"); + expect(rgba.clearRect).toHaveBeenCalledTimes(1); + expect(rgba.fillRect).toHaveBeenCalledTimes(1); + }); + + // the ghosting bug (#10931): a corrupted value must never leave the prior + // frame on screen — we always clear when we can't prove the color is opaque + it("clears for a corrupted color value to prevent ghosting", () => { + expect(run("0000").clearRect).toHaveBeenCalledTimes(1); + expect(run("asdfgh").clearRect).toHaveBeenCalledTimes(1); + }); + + it("falls back to white when the color is rejected by the canvas", () => { + const { canvas, context } = setup(); + // simulate a stale fillStyle left over from a previous frame's drawing + context.fillStyle = "#ff0000"; + let fillStyleAtFillTime = ""; + vi.spyOn(context, "fillRect").mockImplementation(() => { + fillStyleAtFillTime = context.fillStyle as string; + }); + + bootstrapCanvas({ + canvas, + scale: 1, + normalizedWidth: 200, + normalizedHeight: 100, + viewBackgroundColor: "not-a-color", + }); + + // not the stale red — the seeded default + expect(fillStyleAtFillTime).toBe(COLOR_WHITE); + }); + + it("clears for a non-string background", () => { + const { clearRect, fillRect } = run(undefined); + expect(clearRect).toHaveBeenCalledTimes(1); + expect(fillRect).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/excalidraw/renderer/helpers.ts b/packages/excalidraw/renderer/helpers.ts index e5025dcd09..8f8013c42e 100644 --- a/packages/excalidraw/renderer/helpers.ts +++ b/packages/excalidraw/renderer/helpers.ts @@ -1,4 +1,4 @@ -import { THEME, applyDarkModeFilter } from "@excalidraw/common"; +import { COLOR_WHITE, THEME, applyDarkModeFilter } from "@excalidraw/common"; import type { StaticCanvasRenderConfig } from "../scene/types"; import type { AppState, StaticCanvasAppState } from "../types"; @@ -53,21 +53,31 @@ export const bootstrapCanvas = ({ // Paint background if (typeof viewBackgroundColor === "string") { - const hasTransparence = - viewBackgroundColor === "transparent" || - viewBackgroundColor.length === 5 || // #RGBA - viewBackgroundColor.length === 9 || // #RRGGBBA - /(hsla|rgba)\(/.test(viewBackgroundColor); - if (hasTransparence) { + // An opaque fill repaints every pixel, so clearRect would be redundant. + // For anything else — transparency, or a value we can't be certain about + // (e.g. corrupted persisted state like "0000") — clear first so the + // previous frame can't bleed through. + // + // We skip opaque #RRGGBB and #RGB hex colors as a quick optimization. + const isOpaque = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(viewBackgroundColor); + + if (!isOpaque) { context.clearRect(0, 0, normalizedWidth, normalizedHeight); } - context.save(); - context.fillStyle = applyDarkModeFilter( - viewBackgroundColor, - theme === THEME.DARK, - ); - context.fillRect(0, 0, normalizedWidth, normalizedHeight); - context.restore(); + + if (viewBackgroundColor !== "transparent") { + context.save(); + // The canvas silently ignores an invalid fillStyle, which would leave a + // stale color from a previous draw. Seed a sane default so corrupted + // values fall back to white instead of painting garbage. + context.fillStyle = COLOR_WHITE; + context.fillStyle = applyDarkModeFilter( + viewBackgroundColor, + theme === THEME.DARK, + ); + context.fillRect(0, 0, normalizedWidth, normalizedHeight); + context.restore(); + } } else { context.clearRect(0, 0, normalizedWidth, normalizedHeight); } diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts index 48c72f8d37..f3f00256c1 100644 --- a/packages/excalidraw/renderer/staticScene.ts +++ b/packages/excalidraw/renderer/staticScene.ts @@ -1,5 +1,6 @@ import { applyDarkModeFilter, + COLOR_WHITE, FRAME_STYLE, THEME, throttleRAF, @@ -204,7 +205,13 @@ const renderLinkIcon = ( window.devicePixelRatio * appState.zoom.value, window.devicePixelRatio * appState.zoom.value, ); - linkCanvasCacheContext.fillStyle = appState.viewBackgroundColor || "#fff"; + + // Seed a sane default so a corrupted color (silently rejected by the + // canvas) falls back to white instead of a stale fillStyle. + linkCanvasCacheContext.fillStyle = COLOR_WHITE; + linkCanvasCacheContext.fillStyle = + appState.viewBackgroundColor || COLOR_WHITE; + linkCanvasCacheContext.fillRect(0, 0, width, height); if (canvasKey === "elementLink") {