import { COLOR_WHITE, THEME, applyDarkModeFilter } from "@excalidraw/common"; import type { StaticCanvasRenderConfig } from "../scene/types"; import type { AppState, StaticCanvasAppState } from "../types"; export const DEFAULT_SELECTION_COLOR = "#6965db"; /** * Returns the theme's selection color (`--color-selection`), read from the * computed style of any element inside the editor container so that host * overrides are respected. Falls back to the default when unavailable. */ export const getSelectionColor = (element: Element | null | undefined) => (element && getComputedStyle(element).getPropertyValue("--color-selection").trim()) || DEFAULT_SELECTION_COLOR; export const fillCircle = ( context: CanvasRenderingContext2D, cx: number, cy: number, radius: number, stroke: boolean, fill = true, ) => { context.beginPath(); context.arc(cx, cy, radius, 0, Math.PI * 2); if (fill) { context.fill(); } if (stroke) { context.stroke(); } }; /** * 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, ): [number, number] => { // When doing calculations based on canvas width we should used normalized one return [canvas.width / scale, canvas.height / scale]; }; export const bootstrapCanvas = ({ canvas, scale, normalizedWidth, normalizedHeight, theme, isExporting, viewBackgroundColor, }: { canvas: HTMLCanvasElement; scale: number; normalizedWidth: number; normalizedHeight: number; theme?: AppState["theme"]; isExporting?: StaticCanvasRenderConfig["isExporting"]; viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"]; }): CanvasRenderingContext2D => { const context = canvas.getContext("2d")!; context.setTransform(1, 0, 0, 1, 0, 0); context.scale(scale, scale); // Paint background if (typeof viewBackgroundColor === "string") { // 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); } 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); } return context; }; export const strokeRectWithRotation_simple = ( context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, cx: number, cy: number, angle: number, fill: boolean = false, /** should account for zoom */ radius: number = 0, ) => { context.save(); context.translate(cx, cy); context.rotate(angle); if (fill) { context.fillRect(x - cx, y - cy, width, height); } if (radius && context.roundRect) { context.beginPath(); context.roundRect(x - cx, y - cy, width, height, radius); context.stroke(); context.closePath(); } else { context.strokeRect(x - cx, y - cy, width, height); } context.restore(); };