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}