Desktop: Add Eyedropper tool support with native Vello (#3684)

* mostly done

* fix

* kinda works but tilt and flip broken

* fix footprint

Co-authored-by: James Lindsay <78500760+0HyperCube@users.noreply.github.com>

* Code review

* fix cursor hiding

* Remove console.log

---------

Co-authored-by: James Lindsay <78500760+0HyperCube@users.noreply.github.com>
Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Timon
2026-01-27 01:24:09 +00:00
committed by GitHub
co-authored by James Lindsay Keavon Chambers
parent 19e9af3d43
commit 5fd1a24f16
13 changed files with 340 additions and 135 deletions
+49 -30
View File
@@ -212,7 +212,13 @@
});
}
export async function updateEyedropperSamplingState(mousePosition: XY | undefined, colorPrimary: string, colorSecondary: string): Promise<[number, number, number] | undefined> {
export async function updateEyedropperSamplingState(
// `image` is currently only used for Vello renders
image: ImageData | undefined,
mousePosition: XY | undefined,
colorPrimary: string,
colorSecondary: string,
): Promise<[number, number, number] | undefined> {
if (mousePosition === undefined) {
cursorEyedropper = false;
return undefined;
@@ -224,40 +230,52 @@
cursorLeft = mousePosition.x;
cursorTop = mousePosition.y;
// This works nearly perfectly, but sometimes at odd DPI scale factors like 1.25, the anti-aliasing color can yield slightly incorrect colors (potential room for future improvement)
const dpiFactor = window.devicePixelRatio;
const [width, height] = [canvasWidth, canvasHeight];
let preview = image;
if (!preview) {
// This works nearly perfectly, but sometimes at odd DPI scale factors like 1.25, the anti-aliasing color can yield slightly incorrect colors (potential room for future improvement)
const dpiFactor = window.devicePixelRatio;
const [width, height] = [canvasWidth, canvasHeight];
const outsideArtboardsColor = getComputedStyle(window.document.documentElement).getPropertyValue("--color-2-mildblack");
const outsideArtboards = `<rect x="0" y="0" width="100%" height="100%" fill="${outsideArtboardsColor}" />`;
const outsideArtboardsColor = getComputedStyle(window.document.documentElement).getPropertyValue("--color-2-mildblack");
const outsideArtboards = `<rect x="0" y="0" width="100%" height="100%" fill="${outsideArtboardsColor}" />`;
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${outsideArtboards}${artworkSvg}</svg>
`.trim();
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${outsideArtboards}${artworkSvg}</svg>
`.trim();
if (!rasterizedCanvas) {
rasterizedCanvas = await rasterizeSVGCanvas(svg, width * dpiFactor, height * dpiFactor);
rasterizedContext = rasterizedCanvas.getContext("2d", { willReadFrequently: true }) || undefined;
if (!rasterizedCanvas) {
rasterizedCanvas = await rasterizeSVGCanvas(svg, width * dpiFactor, height * dpiFactor);
rasterizedContext = rasterizedCanvas.getContext("2d", { willReadFrequently: true }) || undefined;
}
if (!rasterizedContext) return undefined;
preview = rasterizedContext.getImageData(
mousePosition.x * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
mousePosition.y * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
ZOOM_WINDOW_DIMENSIONS,
ZOOM_WINDOW_DIMENSIONS,
);
if (!preview) return undefined;
}
if (!rasterizedContext) return undefined;
const rgbToHex = (r: number, g: number, b: number): string => `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`;
const centerPixel = (() => {
const { width, height, data } = preview;
const x = Math.floor(width / 2);
const y = Math.floor(height / 2);
const index = (y * width + x) * 4;
return {
r: data[index],
g: data[index + 1],
b: data[index + 2],
};
})();
const hex = [centerPixel.r, centerPixel.g, centerPixel.b].map((x) => x.toString(16).padStart(2, "0")).join("");
const rgb: [number, number, number] = [centerPixel.r / 255, centerPixel.g / 255, centerPixel.b / 255];
const pixel = rasterizedContext.getImageData(mousePosition.x * dpiFactor, mousePosition.y * dpiFactor, 1, 1).data;
const hex = rgbToHex(pixel[0], pixel[1], pixel[2]);
const rgb: [number, number, number] = [pixel[0] / 255, pixel[1] / 255, pixel[2] / 255];
cursorEyedropperPreviewColorChoice = hex;
cursorEyedropperPreviewColorChoice = "#" + hex;
cursorEyedropperPreviewColorPrimary = colorPrimary;
cursorEyedropperPreviewColorSecondary = colorSecondary;
const previewRegion = rasterizedContext.getImageData(
mousePosition.x * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
mousePosition.y * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
ZOOM_WINDOW_DIMENSIONS,
ZOOM_WINDOW_DIMENSIONS,
);
cursorEyedropperPreviewImageData = previewRegion;
cursorEyedropperPreviewImageData = preview;
return rgb;
}
@@ -378,7 +396,7 @@
canvasWidth = Math.ceil(parseFloat(getComputedStyle(viewport).width));
canvasHeight = Math.ceil(parseFloat(getComputedStyle(viewport).height));
devicePixelRatio = window.devicePixelRatio || 1.0;
devicePixelRatio = window.devicePixelRatio || 1;
// Resize the rulers
rulerHorizontal?.resize();
@@ -413,8 +431,9 @@
editor.subscriptions.subscribeJsMessage(UpdateEyedropperSamplingState, async (data) => {
await tick();
const { mousePosition, primaryColor, secondaryColor, setColorChoice } = data;
const rgb = await updateEyedropperSamplingState(mousePosition, primaryColor, secondaryColor);
const { image, mousePosition, primaryColor, secondaryColor, setColorChoice } = data;
const imageData = image !== undefined ? new ImageData(new Uint8ClampedArray(image.data), image.width, image.height) : undefined;
const rgb = await updateEyedropperSamplingState(imageData, mousePosition, primaryColor, secondaryColor);
if (setColorChoice && rgb) {
if (setColorChoice === "Primary") editor.handle.updatePrimaryColor(...rgb, 1);
+8
View File
@@ -678,7 +678,15 @@ export class UpdateDocumentRulers extends JsMessage {
readonly visible!: boolean;
}
export class EyedropperPreviewImage {
readonly data!: Uint8Array;
readonly width!: number;
readonly height!: number;
}
export class UpdateEyedropperSamplingState extends JsMessage {
readonly image!: EyedropperPreviewImage | undefined;
@TupleToVec2
readonly mousePosition!: XY | undefined;