diff --git a/web/libs/editor/jest.setup.js b/web/libs/editor/jest.setup.js
index d1f389cca1d5..ba3cb2c33971 100644
--- a/web/libs/editor/jest.setup.js
+++ b/web/libs/editor/jest.setup.js
@@ -66,3 +66,11 @@ window.HTMLMediaElement.prototype.pause = function pauseMock() {
window.HTMLMediaElement.prototype.canPlayType = function canPlayTypeMock(type) {
return this._mock._supportsTypes.includes(type) ? "maybe" : "";
};
+
+// Polyfill for TextEncoder and TextDecoder for Jest (Node 20+ has them, but jsdom may not expose them)
+if (typeof global.TextEncoder === "undefined") {
+ global.TextEncoder = require("util").TextEncoder;
+}
+if (typeof global.TextDecoder === "undefined") {
+ global.TextDecoder = require("util").TextDecoder;
+}
diff --git a/web/libs/editor/src/components/ImageView/SuggestionControls.jsx b/web/libs/editor/src/components/ImageView/SuggestionControls.jsx
index 49eab76bb0df..5d07ec0b69c4 100644
--- a/web/libs/editor/src/components/ImageView/SuggestionControls.jsx
+++ b/web/libs/editor/src/components/ImageView/SuggestionControls.jsx
@@ -1,10 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { Circle, Group, Image, Layer, Rect } from "react-konva";
-import { IconCheck, IconCross } from "@humansignal/icons";
import Konva from "konva";
import chroma from "chroma-js";
import { observer } from "mobx-react";
import { isDefined } from "../../utils/utilities";
+import ReactDOMServer from "react-dom/server";
+import React from "react";
+import { IconCheck, IconCross } from "../../../../ui/src/assets/icons";
const getItemPosition = (item) => {
const { shapeRef: shape, bboxCoordsCanvas: bbox } = item;
@@ -73,15 +75,15 @@ export const SuggestionControls = observer(({ item, useLayer }) => {
item.annotation.rejectSuggestion(item.id)}
- fill="#DD0000"
- iconColor="#fff"
+ fill="#CC5E46"
+ iconColor="#FFFFFF"
icon={IconCross}
/>
item.annotation.acceptSuggestion(item.id)}
- fill="#98C84E"
- iconColor="#fff"
+ fill="#287A72"
+ iconColor="#FFFFFF"
icon={IconCheck}
/>
@@ -100,10 +102,13 @@ export const SuggestionControls = observer(({ item, useLayer }) => {
const ControlButton = ({ x = 0, fill, iconColor, onClick, icon }) => {
const [img, setImg] = useState(new window.Image());
- const imageSize = 16;
+ const imageSize = 20;
const imageOffset = 32 / 2 - imageSize / 2;
- const color = chroma(iconColor ?? "#fff");
+ const color = chroma(iconColor ?? "#FFFFFF");
const [hovered, setHovered] = useState(false);
+ const [animatedOpacity, setAnimatedOpacity] = useState(0.2);
+ const [animatedFill, setAnimatedFill] = useState("#fff");
+ const animationRef = React.useRef();
useEffect(() => {
const iconImage = new window.Image();
@@ -111,10 +116,43 @@ const ControlButton = ({ x = 0, fill, iconColor, onClick, icon }) => {
iconImage.onload = () => {
setImg(iconImage);
};
- iconImage.width = 12;
- iconImage.height = 12;
- iconImage.src = icon;
- }, [icon]);
+ iconImage.width = 20;
+ iconImage.height = 20;
+
+ const iconElement = React.createElement(icon, { color: iconColor, width: 20, height: 20 });
+ const svgString = ReactDOMServer.renderToStaticMarkup(iconElement);
+ const base64 = btoa(decodeURIComponent(encodeURIComponent(svgString)));
+ iconImage.src = `data:image/svg+xml;base64,${base64}`;
+ }, [icon, iconColor]);
+
+ useEffect(() => {
+ let start;
+ const duration = 150; // ms
+ const easeOut = (t) => 1 - (1 - t) ** 2;
+ const fromOpacity = animatedOpacity;
+ const toOpacity = hovered ? 1 : 0.2;
+ const fromFill = chroma(animatedFill);
+ const toFill = chroma(hovered ? fill : "#fff");
+
+ function animate(now) {
+ if (!start) start = now;
+ const elapsed = now - start;
+ const t = Math.min(1, elapsed / duration);
+ const eased = easeOut(t);
+ setAnimatedOpacity(fromOpacity + (toOpacity - fromOpacity) * eased);
+ setAnimatedFill(chroma.mix(fromFill, toFill, eased, "rgb").hex());
+ if (t < 1) {
+ animationRef.current = requestAnimationFrame(animate);
+ } else {
+ setAnimatedOpacity(toOpacity);
+ setAnimatedFill(toFill.hex());
+ }
+ }
+ cancelAnimationFrame(animationRef.current);
+ animationRef.current = requestAnimationFrame(animate);
+ return () => cancelAnimationFrame(animationRef.current);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [hovered, fill]);
const applyFilter = useCallback(
/**
@@ -142,10 +180,24 @@ const ControlButton = ({ x = 0, fill, iconColor, onClick, icon }) => {
width={32}
height={32}
onClick={onClick}
- onMouseEnter={() => setHovered(true)}
- onMouseLeave={() => setHovered(false)}
+ onMouseEnter={(e) => {
+ setHovered(true);
+ // Set cursor to pointer
+ const stage = e.target.getStage();
+ if (stage && stage.container()) {
+ stage.container().style.cursor = "pointer";
+ }
+ }}
+ onMouseLeave={(e) => {
+ setHovered(false);
+ // Reset cursor
+ const stage = e.target.getStage();
+ if (stage && stage.container()) {
+ stage.container().style.cursor = "";
+ }
+ }}
>
-
+
applyFilter(node)}
x={imageOffset}