Files
2026-09-16 05:31:04 +05:00

158 lines
4.9 KiB
TypeScript

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();
};