Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions web/libs/editor/src/components/ImageView/ImageView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@ import { fixRectToFit, mapKonvaBrightness } from "../../utils/image";
import { FF_DEV_1442, FF_LSDV_4930, FF_ZOOM_OPTIM, isFF } from "../../utils/feature-flags";
import { Pagination } from "../../common/Pagination/Pagination";
import { Image } from "./Image";
import { installTolerantHitDetection } from "../../utils/konva-hit-tolerance";

Konva.showWarnings = false;

// Fix for when some GPU canvas implementations round getImageData by ±1, breaking Konva's
// exact color-key hit detection (most visibly the Transformer resize handles).
installTolerantHitDetection(Konva);

const hotkeys = Hotkey("Image");
const imgDefaultProps = { crossOrigin: "anonymous" };

Expand Down
111 changes: 111 additions & 0 deletions web/libs/editor/src/utils/__tests__/konva-hit-tolerance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { installTolerantHitDetection } from "../konva-hit-tolerance";

// Konva's color key hex, without the leading '#'. Mirrors Konva.Util._rgbToHex.
const rgbToHex = (r: number, g: number, b: number) => ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);

/**
* Minimal stand-in for Konva that reproduces the exact-color hit lookup the
* patch wraps, so we can drive it without a real canvas/GPU.
*/
function makeFakeKonva() {
const shapes: Record<string, unknown> = {};

// Faithful reimplementation of Konva.Layer.prototype._getIntersection.
function _getIntersection(this: any, pos: { x: number; y: number }) {
const ratio = this.hitCanvas.pixelRatio;
const p = this.hitCanvas.context.getImageData(Math.round(pos.x * ratio), Math.round(pos.y * ratio), 1, 1).data;
if (p[3] === 255) {
const shape = shapes[`#${rgbToHex(p[0], p[1], p[2])}`];
if (shape) return { shape };
return { antialiased: true };
}
if (p[3] > 0) return { antialiased: true };
return {};
}

return {
Layer: { prototype: { _getIntersection } },
Util: { _rgbToHex: rgbToHex },
shapes,
} as any;
}

// Build a fake layer whose hit canvas returns a fixed pixel under the pointer.
const layerReading = (rgba: [number, number, number, number]) => ({
hitCanvas: {
pixelRatio: 1,
context: { getImageData: () => ({ data: rgba }) },
},
});

const intersect = (konva: any, rgba: [number, number, number, number]) =>
konva.Layer.prototype._getIntersection.call(layerReading(rgba), { x: 5, y: 5 });

describe("installTolerantHitDetection", () => {
it("leaves exact color-key matches untouched", () => {
const konva = makeFakeKonva();
const shape = { id: "exact" };
konva.shapes[`#${rgbToHex(36, 94, 150)}`] = shape;
installTolerantHitDetection(konva);

expect(intersect(konva, [36, 94, 150, 255]).shape).toBe(shape);
});

it("resolves a shape when the read-back pixel drifts by ±1 per channel", () => {
const konva = makeFakeKonva();
const shape = { id: "drifted" };
konva.shapes[`#${rgbToHex(36, 94, 150)}`] = shape; // registered key
installTolerantHitDetection(konva);

// GPU returned (37,95,150) instead of (36,94,150) — would miss without the patch.
expect(intersect(konva, [37, 95, 150, 255]).shape).toBe(shape);
// Without the patch this opaque miss is reported as antialiased.
const konvaUnpatched = makeFakeKonva();
konvaUnpatched.shapes[`#${rgbToHex(36, 94, 150)}`] = shape;
expect(intersect(konvaUnpatched, [37, 95, 150, 255]).shape).toBeUndefined();
});

it("does not invent a hit for an opaque pixel with no ±1 neighbor registered", () => {
const konva = makeFakeKonva();
konva.shapes[`#${rgbToHex(10, 20, 30)}`] = { id: "far-away" };
installTolerantHitDetection(konva);

const res = intersect(konva, [200, 100, 50, 255]);
expect(res.shape).toBeUndefined();
expect(res.antialiased).toBe(true); // original behaviour preserved
});

it("ignores transparent / partial-alpha pixels (real edges)", () => {
const konva = makeFakeKonva();
konva.shapes[`#${rgbToHex(36, 94, 150)}`] = { id: "s" };
installTolerantHitDetection(konva);

expect(intersect(konva, [37, 95, 150, 0]).shape).toBeUndefined(); // transparent
expect(intersect(konva, [37, 95, 150, 128]).antialiased).toBe(true); // edge
});

it("clamps channel search at the 0/255 boundaries", () => {
const konva = makeFakeKonva();
const shape = { id: "edge-color" };
konva.shapes[`#${rgbToHex(0, 255, 1)}`] = shape;
installTolerantHitDetection(konva);

// Read-back drifted to (1,254,0); neighbor search must not overflow channels.
expect(intersect(konva, [1, 254, 0, 255]).shape).toBe(shape);
});

it("is idempotent (does not double-wrap)", () => {
const konva = makeFakeKonva();
const wrappedOnce = (() => {
installTolerantHitDetection(konva);
return konva.Layer.prototype._getIntersection;
})();
installTolerantHitDetection(konva);
expect(konva.Layer.prototype._getIntersection).toBe(wrappedOnce);
});

it("no-ops safely when Konva internals are missing", () => {
expect(() => installTolerantHitDetection({} as any)).not.toThrow();
expect(() => installTolerantHitDetection({ Layer: { prototype: {} } } as any)).not.toThrow();
});
});
84 changes: 84 additions & 0 deletions web/libs/editor/src/utils/konva-hit-tolerance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import Konva from "konva";

/**
* Make Konva's color-key hit detection tolerant of ±1 RGB drift.
*
* Konva identifies which shape is under the pointer by drawing every shape to
* an offscreen "hit" canvas filled with a unique color key, then reading the
* pixel under the pointer back with `getImageData` and looking the color up in
* `Konva.shapes`. This requires `getImageData` to return the *exact* bytes that
* were written.
*
* On some GPU-accelerated canvas implementations (observed on Chrome / Linux,
* regardless of `willReadFrequently`) `getImageData` rounds solid colors by ±1
* per channel. The exact lookup then misses, and the click "falls through".
* This hits small targets hardest — most visibly the 8px image Transformer
* resize handles, which become unclickable when zoomed (a non-integer stage
* scale makes the miss rate worse), and occasionally drops region-selection
* clicks too.
*
* Konva's built-in recovery (spiral search to neighbouring pixel *positions*)
* doesn't help here: the whole anchor footprint reads the same drifted color,
* so no nearby position carries the exact key either.
*
* Fix: on an opaque-pixel miss, search the 26 color keys within ±1 RGB of the
* read-back value and resolve to that shape. The true shape's exact key is by
* definition one of those neighbours (the read-back drifted from it by ≤1).
* Color keys are spread across 16.7M values, so the chance a ±1 neighbour is a
* *different* registered shape is negligible (~0.02% worst case, a single-pixel
* mishit). The extra work only runs on a miss, so healthy hits pay nothing.
*/

const PATCH_FLAG = "__lsTolerantHitPatched";
const HASH = "#";

type HitLayer = {
hitCanvas: { pixelRatio: number; context: { getImageData(x: number, y: number, w: number, h: number): ImageData } };
};

type IntersectionResult = { shape?: unknown; antialiased?: boolean };

export function installTolerantHitDetection(konva: typeof Konva = Konva): void {
const konvaAny = konva as unknown as Record<string, any>;
const layerProto = konva.Layer?.prototype as unknown as
| { _getIntersection?: (pos: { x: number; y: number }) => IntersectionResult }
| undefined;

// Bail out gracefully if already patched or if Konva's internals changed
// shape (e.g. after a version upgrade) — never break hit detection.
if (!layerProto || typeof layerProto._getIntersection !== "function") return;
if (konvaAny[PATCH_FLAG]) return;

const original = layerProto._getIntersection;
const rgbToHex = konva.Util?._rgbToHex?.bind(konva.Util);
if (typeof rgbToHex !== "function") return;

layerProto._getIntersection = function (this: HitLayer, pos: { x: number; y: number }): IntersectionResult {
const result = original.call(this, pos);
// Exact match (or no opaque pixel) — keep Konva's behaviour untouched.
if (result.shape) return result;

const ratio = this.hitCanvas.pixelRatio;
const p = this.hitCanvas.context.getImageData(Math.round(pos.x * ratio), Math.round(pos.y * ratio), 1, 1).data;
// Only fully-opaque pixels carry a color key; partial alpha is a real edge.
if (p[3] !== 255) return result;

for (let dr = -1; dr <= 1; dr++) {
for (let dg = -1; dg <= 1; dg++) {
for (let db = -1; db <= 1; db++) {
if (!dr && !dg && !db) continue; // exact key already tried by `original`
const r = p[0] + dr;
const g = p[1] + dg;
const b = p[2] + db;
if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) continue;
const shape = konvaAny.shapes[HASH + rgbToHex(r, g, b)];
if (shape) return { shape };
}
}
}

return result;
};

konvaAny[PATCH_FLAG] = true;
}
Loading