diff --git a/web/libs/editor/src/components/ImageView/ImageView.jsx b/web/libs/editor/src/components/ImageView/ImageView.jsx index 7e9be8ca7770..33865f3de280 100644 --- a/web/libs/editor/src/components/ImageView/ImageView.jsx +++ b/web/libs/editor/src/components/ImageView/ImageView.jsx @@ -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" }; diff --git a/web/libs/editor/src/utils/__tests__/konva-hit-tolerance.test.ts b/web/libs/editor/src/utils/__tests__/konva-hit-tolerance.test.ts new file mode 100644 index 000000000000..e5e2b4721d6b --- /dev/null +++ b/web/libs/editor/src/utils/__tests__/konva-hit-tolerance.test.ts @@ -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 = {}; + + // 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(); + }); +}); diff --git a/web/libs/editor/src/utils/konva-hit-tolerance.ts b/web/libs/editor/src/utils/konva-hit-tolerance.ts new file mode 100644 index 000000000000..597989c88bb2 --- /dev/null +++ b/web/libs/editor/src/utils/konva-hit-tolerance.ts @@ -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; + 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; +}