import { ff } from "@humansignal/core";
import { inject } from "mobx-react";
import { destroy, getRoot, getType, types } from "mobx-state-tree";
import ImageView from "../../../components/ImageView/ImageView";
import { customTypes } from "../../../core/CustomTypes";
import Registry from "../../../core/Registry";
import { AnnotationMixin } from "../../../mixins/AnnotationMixin";
import { IsReadyWithDepsMixin } from "../../../mixins/IsReadyMixin";
import { BrushRegionModel } from "../../../regions/BrushRegion";
import { EllipseRegionModel } from "../../../regions/EllipseRegion";
import { KeyPointRegionModel } from "../../../regions/KeyPointRegion";
import { PolygonRegionModel } from "../../../regions/PolygonRegion";
import { VectorRegionModel } from "../../../regions/VectorRegion";
import { RectRegionModel } from "../../../regions/RectRegion";
import * as Tools from "../../../tools";
import ToolsManager from "../../../tools/Manager";
import { parseValue } from "../../../utils/data";
import {
FF_DEV_3377,
FF_DEV_3391,
FF_DEV_3793,
FF_LSDV_4583,
FF_LSDV_4583_6,
FF_ZOOM_OPTIM,
isFF,
} from "../../../utils/feature-flags";
import { guidGenerator } from "../../../utils/unique";
import { clamp, isDefined } from "../../../utils/utilities";
import ObjectBase from "../Base";
import { DrawingRegion } from "./DrawingRegion";
import { ImageEntityMixin } from "./ImageEntityMixin";
import { ImageSelection } from "./ImageSelection";
import { RELATIVE_STAGE_HEIGHT, RELATIVE_STAGE_WIDTH, SNAP_TO_PIXEL_MODE } from "../../../components/ImageView/Image";
import MultiItemObjectBase from "../MultiItemObjectBase";
const IMAGE_PRELOAD_COUNT = 3;
const ZOOM_INTENSITY = 0.009;
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 100;
const MAX_ZOOM_CHANGE_PER_EVENT = 0.3; // Maximum zoom change per wheel event (30%)
/**
* The `Image` tag shows an image on the page. Use for all image annotation tasks to display an image on the labeling interface.
*
* Use with the following data types: images.
*
* When you annotate image regions with this tag, the annotations are saved as percentages of the original size of the image, from 0-100.
*
* @example
*
*
*
*
*
*
* @example
*
*
*
*
*
*
*
* @name Image
* @meta_title Image Tags for Images
* @meta_description Customize Label Studio with the Image tag to annotate images for computer vision machine learning and data science projects.
* @param {string} name - Name of the element
* @param {string} value - Data field containing a path or URL to the image
* @param {string} [valueList] - References a variable that holds a list of image URLs. For an example, see the [Multi-Page Document Annotation](/templates/multi-page-document-annotation) template.
* @param {boolean} [smoothing] - Enable smoothing, by default it uses user settings
* @param {string=} [width=100%] - Image width
* @param {string=} [maxWidth=750px] - Maximum image width
* @param {boolean=} [zoom=false] - Enable zooming an image with the mouse wheel
* @param {boolean=} [negativeZoom=false] - Enable zooming out an image
* @param {float=} [zoomBy=1.1] - Scale factor
* @param {boolean=} [grid=false] - Whether to show a grid
* @param {number=} [gridSize=30] - Specify size of the grid
* @param {string=} [gridColor=#EEEEF4] - Color of the grid in hex, opacity is 0.15
* @param {boolean} [zoomControl=false] - Show zoom controls in toolbar
* @param {boolean} [brightnessControl=false] - Show brightness control in toolbar
* @param {boolean} [contrastControl=false] - Show contrast control in toolbar
* @param {boolean} [rotateControl=false] - Show rotate control in toolbar
* @param {boolean} [crosshair=false] - Show crosshair cursor
* @param {left|center|right} [horizontalAlignment=left] - Where to align image horizontally. Can be one of "left", "center", or "right"
* @param {top|center|bottom} [verticalAlignment=top] - Where to align image vertically. Can be one of "top", "center", or "bottom"
* @param {auto|original|fit} [defaultZoom=fit] - Specify the initial zoom of the image within the viewport while preserving its ratio. Can be one of "auto", "original", or "fit"
* @param {none|anonymous|use-credentials} [crossOrigin=none] - Configures CORS cross domain behavior for this image, either "none", "anonymous", or "use-credentials", similar to [DOM `img` crossOrigin property](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/crossOrigin).
*/
const TagAttrs = types.model({
value: types.maybeNull(types.string),
valuelist: types.maybeNull(types.string),
resize: types.maybeNull(types.number),
width: types.optional(types.string, "100%"),
height: types.maybeNull(types.string),
maxwidth: types.optional(types.string, "100%"),
maxheight: types.optional(types.string, "calc(100vh - 194px)"),
smoothing: types.maybeNull(types.boolean),
// rulers: types.optional(types.boolean, true),
grid: types.optional(types.boolean, false),
gridsize: types.optional(types.string, "30"),
gridcolor: types.optional(customTypes.color, "#EEEEF4"),
zoom: types.optional(types.boolean, true),
negativezoom: types.optional(types.boolean, false),
zoomby: types.optional(types.string, "1.1"),
showlabels: types.optional(types.boolean, false),
zoomcontrol: types.optional(types.boolean, true),
brightnesscontrol: types.optional(types.boolean, false),
contrastcontrol: types.optional(types.boolean, false),
rotatecontrol: types.optional(types.boolean, false),
crosshair: types.optional(types.boolean, false),
selectioncontrol: types.optional(types.boolean, true),
// this property is just to turn lazyload off to e2e tests
lazyoff: types.optional(types.boolean, false),
horizontalalignment: types.optional(types.enumeration(["left", "center", "right"]), "left"),
verticalalignment: types.optional(types.enumeration(["top", "center", "bottom"]), "top"),
defaultzoom: types.optional(types.enumeration(["auto", "original", "fit"]), "fit"),
crossorigin: types.optional(types.enumeration(["none", "anonymous", "use-credentials"]), "none"),
});
const IMAGE_CONSTANTS = {
rectangleModel: "RectangleModel",
rectangleLabelsModel: "RectangleLabelsModel",
ellipseModel: "EllipseModel",
ellipseLabelsModel: "EllipseLabelsModel",
brushLabelsModel: "BrushLabelsModel",
rectanglelabels: "rectanglelabels",
keypointlabels: "keypointlabels",
polygonlabels: "polygonlabels",
vectorlabels: "vectorlabels",
brushlabels: "brushlabels",
bitmaskModel: "BitmaskModel",
bitmasklabels: "bitmasklabels",
brushModel: "BrushModel",
ellipselabels: "ellipselabels",
};
const Model = types
.model({
type: "image",
// tools: types.array(BaseTool),
sizeUpdated: types.optional(types.boolean, false),
/**
* Cursor coordinates
*/
cursorPositionX: types.optional(types.number, 0),
cursorPositionY: types.optional(types.number, 0),
brushControl: types.optional(types.string, "brush"),
brushStrokeWidth: types.optional(types.number, 15),
/**
* Mode
* brush for Image Segmentation
* eraser for Image Segmentation
*/
mode: types.optional(types.enumeration(["drawing", "viewing", "brush", "eraser"]), "viewing"),
regions: types.array(
types.union(
BrushRegionModel,
RectRegionModel,
EllipseRegionModel,
PolygonRegionModel,
VectorRegionModel,
KeyPointRegionModel,
),
[],
),
drawingRegion: types.optional(DrawingRegion, null),
selectionArea: types.optional(ImageSelection, { start: null, end: null }),
})
.volatile(() => ({
currentImage: undefined,
supportSuggestions: true,
}))
.views((self) => ({
get store() {
return getRoot(self);
},
get multiImage() {
return !!self.isMultiItem;
},
// an alias of currentImage to make an interface reusable
get currentItemIndex() {
return self.currentImage;
},
get parsedValue() {
return parseValue(self.value, self.store.task.dataObj);
},
get parsedValueList() {
return parseValue(self.valuelist, self.store.task.dataObj);
},
get currentSrc() {
return self.currentImageEntity.src;
},
get usedValue() {
return self.multiImage ? self.valuelist : self.value;
},
get images() {
const value = self.parsedValue;
if (!value) return [];
if (Array.isArray(value)) return value;
return [value];
},
/**
* @return {boolean}
*/
get hasStates() {
const states = self.states();
return states && states.length > 0;
},
get selectedRegions() {
return self.regs.filter((region) => region.inSelection);
},
get selectedRegionsBBox() {
let bboxCoords;
self.selectedRegions.forEach((region) => {
const regionBBox = region.bboxCoords;
if (!regionBBox) return;
if (bboxCoords) {
bboxCoords = {
left: Math.min(regionBBox?.left, bboxCoords.left),
top: Math.min(regionBBox?.top, bboxCoords.top),
right: Math.max(regionBBox?.right, bboxCoords.right),
bottom: Math.max(regionBBox?.bottom, bboxCoords.bottom),
};
} else {
bboxCoords = regionBBox;
}
});
return bboxCoords;
},
get regionsInSelectionArea() {
return self.regs.filter((region) => region.isInSelectionArea);
},
get selectedShape() {
return self.regs.find((r) => r.selected);
},
get suggestions() {
return self.annotation?.regionStore.suggestions.filter((r) => r.object === self) || [];
},
get useTransformer() {
return self.getToolsManager().findSelectedTool()?.useTransformer === true;
},
get stageTranslate() {
const { stageWidth: width, stageHeight: height } = self;
return {
0: { x: 0, y: 0 },
90: { x: 0, y: height },
180: { x: width, y: height },
270: { x: width, y: 0 },
}[self.rotation];
},
get stageScale() {
return self.zoomScale;
},
get layerZoomScalePosition() {
return {
scaleX: self.zoomScale,
scaleY: self.zoomScale,
x: self.zoomingPositionX + self.alignmentOffset.x,
y: self.zoomingPositionY + self.alignmentOffset.y,
};
},
get hasTools() {
return !!self.getToolsManager().allTools()?.length;
},
get imageCrossOrigin() {
const value = self.crossorigin.toLowerCase();
if (!value || value === "none") return "anonymous";
return value;
},
get fillerHeight() {
const { naturalWidth, naturalHeight } = self;
return self.isSideways ? `${(naturalWidth / naturalHeight) * 100}%` : `${(naturalHeight / naturalWidth) * 100}%`;
},
get zoomedPixelSize() {
const { naturalWidth, naturalHeight } = self;
if (isFF(FF_DEV_3793)) {
return {
x: 100 / naturalWidth,
y: 100 / naturalHeight,
};
}
return {
x: self.stageWidth / naturalWidth,
y: self.stageHeight / naturalHeight,
};
},
isSamePixel({ x: x1, y: y1 }, { x: x2, y: y2 }) {
const zoomedPixelSizeX = self.zoomedPixelSize.x;
const zoomedPixelSizeY = self.zoomedPixelSize.y;
return Math.abs(x1 - x2) < zoomedPixelSizeX / 2 && Math.abs(y1 - y2) < zoomedPixelSizeY / 2;
},
snapPointToPixel({ x, y }, snapMode = SNAP_TO_PIXEL_MODE.EDGE) {
const zoomedPixelSizeX = self.zoomedPixelSize.x;
const zoomedPixelSizeY = self.zoomedPixelSize.y;
switch (snapMode) {
case SNAP_TO_PIXEL_MODE.EDGE: {
return {
x: Math.round(x / zoomedPixelSizeX) * zoomedPixelSizeX,
y: Math.round(y / zoomedPixelSizeY) * zoomedPixelSizeY,
};
}
case SNAP_TO_PIXEL_MODE.CENTER: {
return {
x: Math.floor(x / zoomedPixelSizeX) * zoomedPixelSizeX + zoomedPixelSizeX / 2,
y: Math.floor(y / zoomedPixelSizeY) * zoomedPixelSizeY + zoomedPixelSizeY / 2,
};
}
}
},
createSerializedResult(region, value) {
const index = region.item_index ?? 0;
const currentImageEntity = self.findImageEntity(index);
const imageDimension = {
original_width: currentImageEntity.naturalWidth,
original_height: currentImageEntity.naturalHeight,
image_rotation: currentImageEntity.rotation,
};
if (self.multiImage && isDefined(index)) {
imageDimension.item_index = index;
}
// We're using raw region result instead of calulated one when
// the image data is not available (image is not yet loaded)
// As the serialization also happens during region creation,
// we have to forsee this scenario and avoid using raw result
// as it can only be present for already created (submitter) regions
const useRawResult = !currentImageEntity.imageLoaded && isDefined(region._rawResult);
return useRawResult
? structuredClone(region._rawResult)
: {
...imageDimension,
value,
};
},
/**
* @return {object}
*/
states() {
return self.annotation.toNames.get(self.name);
},
activeStates() {
const states = self.states();
return states && states.filter((s) => s.isSelected && s.type.includes("labels"));
},
controlButton() {
const names = self.states();
if (!names || names.length === 0) return;
let returnedControl = names[0];
names.forEach((item) => {
if (
item.type === IMAGE_CONSTANTS.rectanglelabels ||
item.type === IMAGE_CONSTANTS.brushlabels ||
item.type === IMAGE_CONSTANTS.bitmasklabels ||
item.type === IMAGE_CONSTANTS.ellipselabels
) {
returnedControl = item;
}
});
return returnedControl;
},
get controlButtonType() {
const name = self.controlButton();
return getType(name).name;
},
get isSideways() {
return (self.rotation + 360) % 180 === 90;
},
get stageComponentSize() {
if (self.isSideways) {
return {
width: self.stageHeight,
height: self.stageWidth,
};
}
return {
width: self.stageWidth,
height: self.stageHeight,
};
},
get canvasSize() {
if (self.isSideways) {
return {
width: isFF(FF_DEV_3377)
? self.naturalHeight * self.stageZoomX
: Math.round(self.naturalHeight * self.stageZoomX),
height: isFF(FF_DEV_3377)
? self.naturalWidth * self.stageZoomY
: Math.round(self.naturalWidth * self.stageZoomY),
};
}
return {
width: isFF(FF_DEV_3377)
? self.naturalWidth * self.stageZoomX
: Math.round(self.naturalWidth * self.stageZoomX),
height: isFF(FF_DEV_3377)
? self.naturalHeight * self.stageZoomY
: Math.round(self.naturalHeight * self.stageZoomY),
};
},
get alignmentOffset() {
const offset = { x: 0, y: 0 };
if (isFF(FF_ZOOM_OPTIM)) {
switch (self.horizontalalignment) {
case "center": {
offset.x = (self.containerWidth - self.canvasSize.width) / 2;
break;
}
case "right": {
offset.x = self.containerWidth - self.canvasSize.width;
break;
}
}
switch (self.verticalalignment) {
case "center": {
offset.y = (self.containerHeight - self.canvasSize.height) / 2;
break;
}
case "bottom": {
offset.y = self.containerHeight - self.canvasSize.height;
break;
}
}
}
return offset;
},
get zoomBy() {
return Number.parseFloat(self.zoomby);
},
get isDrawing() {
return !!self.drawingRegion;
},
get imageTransform() {
const imgStyle = {
// scale transform leaves gaps on image border, so much better to change image sizes
width: `${self.stageWidth * self.zoomScale}px`,
height: `${self.stageHeight * self.zoomScale}px`,
transformOrigin: "left top",
// We should always set some transform to make the image rendering in the same way all the time
transform: "translate3d(0,0,0)",
filter: `brightness(${self.brightnessGrade}%) contrast(${self.contrastGrade}%)`,
};
const imgTransform = [];
if (self.zoomScale !== 1) {
const { zoomingPositionX = 0, zoomingPositionY = 0 } = self;
imgTransform.push(`translate3d(${zoomingPositionX}px,${zoomingPositionY}px, 0)`);
}
if (self.rotation) {
const translate = {
90: "0, -100%",
180: "-100%, -100%",
270: "-100%, 0",
};
// there is a top left origin already set for zoom; so translate+rotate
imgTransform.push(`rotate(${self.rotation}deg)`);
imgTransform.push(`translate(${translate[self.rotation] || "0, 0"})`);
}
if (imgTransform?.length > 0) {
imgStyle.transform = imgTransform.join(" ");
}
return imgStyle;
},
get maxScale() {
return self.isSideways
? Math.min(self.containerWidth / self.naturalHeight, self.containerHeight / self.naturalWidth)
: Math.min(self.containerWidth / self.naturalWidth, self.containerHeight / self.naturalHeight);
},
get coverScale() {
return self.isSideways
? Math.max(self.containerWidth / self.naturalHeight, self.containerHeight / self.naturalWidth)
: Math.max(self.containerWidth / self.naturalWidth, self.containerHeight / self.naturalHeight);
},
get viewPortBBoxCoords() {
let width = self.canvasSize.width / self.zoomScale;
let height = self.canvasSize.height / self.zoomScale;
const leftOffset = -self.zoomingPositionX / self.zoomScale;
const topOffset = -self.zoomingPositionY / self.zoomScale;
const rightOffset = self.stageComponentSize.width - (leftOffset + width);
const bottomOffset = self.stageComponentSize.height - (topOffset + height);
const offsets = [leftOffset, topOffset, rightOffset, bottomOffset];
if (self.isSideways) {
[width, height] = [height, width];
}
if (self.rotation) {
const rotateCount = (self.rotation / 90) % 4;
for (let k = 0; k < rotateCount; k++) {
offsets.push(offsets.shift());
}
}
const left = offsets[0];
const top = offsets[1];
return {
left,
top,
right: left + width,
bottom: top + height,
width,
height,
};
},
}))
.volatile((self) => ({
manager: null,
}))
// actions for the tools
.actions((self) => {
const manager = ToolsManager.getInstance({ name: self.name });
const env = { manager, control: self, object: self };
function createImageEntities() {
if (!self.store.task) return;
// Clear existing entities to prevent duplicates from React StrictMode double mounting
self.imageEntities.clear();
const parsedValue = self.multiImage ? self.parsedValueList : self.parsedValue;
const idPostfix = self.annotation ? `@${self.annotation.id}` : "";
if (Array.isArray(parsedValue)) {
parsedValue.forEach((src, index) => {
self.imageEntities.push({
id: `${self.name}#${index}${idPostfix}`,
src,
index,
});
});
} else {
self.imageEntities.push({
id: `${self.name}#0${idPostfix}`,
src: parsedValue,
index: 0,
});
}
self.setCurrentImage(0);
}
function afterAttach() {
if (ff.isActive(FF_DEV_3391) && !self.annotation) {
return;
}
if (self.selectioncontrol) manager.addTool("MoveTool", Tools.Selection.create({}, env), "MoveTool");
if (self.zoomcontrol) manager.addTool("ZoomPanTool", Tools.Zoom.create({}, env), "ZoomPanTool");
if (self.brightnesscontrol) manager.addTool("BrightnessTool", Tools.Brightness.create({}, env), "BrightnessTool");
if (self.contrastcontrol) manager.addTool("ContrastTool", Tools.Contrast.create({}, env), "ContrastTool");
if (self.rotatecontrol) manager.addTool("RotateTool", Tools.Rotate.create({}, env), "RotateTool");
createImageEntities();
}
function afterResultCreated(region) {
if (!region) return;
if (region.classification) return;
if (!self.multiImage) return;
region.setItemIndex?.(self.currentImage);
}
function getToolsManager() {
return manager;
}
return {
afterAttach,
getToolsManager,
afterResultCreated,
};
})
.extend((self) => {
let skipInteractions = false;
return {
views: {
getSkipInteractions() {
if (isFF(FF_ZOOM_OPTIM)) {
if (skipInteractions) return true;
const isLinkingMode = self.annotation.isLinkingMode;
if (isLinkingMode) return false;
const manager = self.getToolsManager();
const tool = manager.findSelectedTool();
const canInteractWithRegions = tool?.canInteractWithRegions;
return !canInteractWithRegions;
}
const manager = self.getToolsManager();
const isPanning = manager.findSelectedTool()?.toolName === "ZoomPanTool";
return skipInteractions || isPanning;
},
get smoothingEnabled() {
const names = self.annotation?.names;
if (!names) return self.smoothing;
const hasBitmask = Array.from(names.values()).some(({ type }) => {
return type.includes("bitmask");
});
if (hasBitmask) return false;
return self.smoothing;
},
},
actions: {
setSkipInteractions(value) {
skipInteractions = value;
},
updateSkipInteractions(e) {
const currentTool = self.getToolsManager().findSelectedTool();
if (currentTool?.shouldSkipInteractions) {
return self.setSkipInteractions(currentTool.shouldSkipInteractions(e));
}
self.setSkipInteractions(e.evt && (e.evt.metaKey || e.evt.ctrlKey));
},
},
};
})
.actions((self) => ({
freezeHistory() {
//self.annotation.history.freeze();
},
afterRegionSelected(region) {
if (self.multiImage) {
self.setCurrentImage(region.item_index);
}
},
createDrawingRegion(areaValue, resultValue, control, dynamic) {
const controlTag = self.annotation.names.get(control.name);
const result = {
from_name: controlTag,
to_name: self,
type: control.resultType,
value: resultValue,
};
const areaRaw = {
id: guidGenerator(),
object: self,
...areaValue,
results: [result],
dynamic,
item_index: self.currentImage,
};
self.drawingRegion = areaRaw;
return self.drawingRegion;
},
deleteDrawingRegion() {
const { drawingRegion } = self;
if (!drawingRegion) return;
self.drawingRegion = null;
destroy(drawingRegion);
},
setSelectionStart(point) {
self.selectionArea.setStart(point);
},
setSelectionEnd(point) {
self.selectionArea.setEnd(point);
},
resetSelection() {
self.selectionArea.setStart(null);
self.selectionArea.setEnd(null);
},
updateBrushControl(arg) {
self.brushControl = arg;
},
updateBrushStrokeWidth(arg) {
self.brushStrokeWidth = arg;
},
/**
* Update brightnessGrade of Image
* @param {number} value
*/
setBrightnessGrade(value) {
self.brightnessGrade = value;
},
setContrastGrade(value) {
self.contrastGrade = value;
},
setGridSize(value) {
self.gridsize = String(value);
},
// an alias of setCurrentImage for making an interface reusable
setCurrentItem(index = 0) {
self.setCurrentImage(index);
},
setCurrentImage(index = 0) {
index = index ?? 0;
if (index === self.currentImage) return;
self.currentImage = index;
self.currentImageEntity = self.findImageEntity(index);
if (isFF(FF_LSDV_4583_6)) self.preloadImages();
},
preloadImages() {
self.currentImageEntity.setImageLoaded(false);
self.currentImageEntity.preload();
if (self.multiImage) {
const [currentIndex, length] = [self.currentImage, self.imageEntities.length];
const prevSliceIndex = clamp(currentIndex - IMAGE_PRELOAD_COUNT, 0, currentIndex);
const nextSliceIndex = clamp(currentIndex + 1 + IMAGE_PRELOAD_COUNT, currentIndex, length - 1);
const images = [
...self.imageEntities.slice(prevSliceIndex, currentIndex),
...self.imageEntities.slice(currentIndex + 1, nextSliceIndex),
];
images.forEach((imageEntity) => {
imageEntity.preload();
});
}
},
/**
* Set pointer of X and Y
*/
setPointerPosition({ x, y }) {
self.freezeHistory();
self.cursorPositionX = x;
self.cursorPositionY = y;
},
/**
* Set zoom
*/
setZoom(scale) {
scale = clamp(scale, 1, Number.POSITIVE_INFINITY);
self.currentZoom = scale;
// cool comment about all this stuff
const maxScale = self.maxScale;
const coverScale = self.coverScale;
if (maxScale > 1) {
// image < container
if (scale < maxScale) {
// scale = 1 or before stage size is max
self.stageZoom = scale; // scale stage
self.zoomScale = 1; // don't scale image
} else {
self.stageZoom = maxScale; // scale stage to max
self.zoomScale = scale / maxScale; // scale image for the rest scale
}
} else {
// image > container
if (scale > maxScale) {
// scale = 1 or any other zoom bigger then viewport
self.stageZoom = maxScale; // stage squizzed
self.zoomScale = scale; // scale image for the rest scale : scale image usually
} else {
// negative zoom bigger than image negative scale
self.stageZoom = scale; // squize stage more
self.zoomScale = 1; // don't scale image
}
}
if (self.zoomScale > 1) {
// zoomScale scales image above maxScale, so scale the rest of stage the same way
const z = Math.min(maxScale * self.zoomScale, coverScale);
if (self.containerWidth / self.naturalWidth > self.containerHeight / self.naturalHeight) {
self.stageZoomX = z;
self.stageZoomY = self.stageZoom;
} else {
self.stageZoomX = self.stageZoom;
self.stageZoomY = z;
}
} else {
self.stageZoomX = self.stageZoom;
self.stageZoomY = self.stageZoom;
}
},
updateImageAfterZoom() {
const { stageWidth, stageHeight } = self;
self._recalculateImageParams();
if (stageWidth !== self.stageWidth || stageHeight !== self.stageHeight) {
self._updateRegionsSizes({
width: self.stageWidth,
height: self.stageHeight,
naturalWidth: self.naturalWidth,
naturalHeight: self.naturalHeight,
});
}
},
setZoomPosition(x, y) {
const [width, height] = isFF(FF_DEV_3377)
? [self.canvasSize.width, self.canvasSize.height]
: [self.containerWidth, self.containerHeight];
const [minX, minY] = [
width - self.stageComponentSize.width * self.zoomScale,
height - self.stageComponentSize.height * self.zoomScale,
];
self.zoomingPositionX = clamp(x, minX, 0);
self.zoomingPositionY = clamp(y, minY, 0);
},
resetZoomPositionToCenter() {
const { stageComponentSize, zoomScale } = self;
const { width, height } = stageComponentSize;
const [containerWidth, containerHeight] = isFF(FF_DEV_3377)
? [self.canvasSize.width, self.canvasSize.height]
: [self.containerWidth, self.containerHeight];
self.setZoomPosition((containerWidth - width * zoomScale) / 2, (containerHeight - height * zoomScale) / 2);
},
sizeToFit() {
const { maxScale } = self;
self.defaultzoom = "fit";
self.setZoom(maxScale);
self.updateImageAfterZoom();
self.resetZoomPositionToCenter();
},
sizeToOriginal() {
const { maxScale } = self;
self.defaultzoom = "original";
self.setZoom(maxScale > 1 ? 1 : 1 / maxScale);
self.updateImageAfterZoom();
self.resetZoomPositionToCenter();
},
sizeToAuto() {
self.defaultzoom = "auto";
self.setZoom(1);
self.updateImageAfterZoom();
self.resetZoomPositionToCenter();
},
getInertialZoom(val) {
const invert = getRoot(self).settings.invertedZoom ? 1 : -1;
// Invert the delta value so that:
// - Pinch out (positive deltaY) zooms in
// - Pinch in (negative deltaY) zooms out
// - Scroll up (positive deltaY) zooms in
// - Scroll down (negative deltaY) zooms out
const invertedVal = val * invert;
// Calculate the zoom change using exponential formula
// This provides smooth zooming for both mouse wheel and trackpad pinch
const zoomChange = Math.exp(invertedVal * ZOOM_INTENSITY);
// Limit the maximum zoom change per event to prevent aggressive zooming
// This prevents users from accidentally zooming too far with a single wheel event
const limitedZoomChange = Math.max(
1 - MAX_ZOOM_CHANGE_PER_EVENT,
Math.min(1 + MAX_ZOOM_CHANGE_PER_EVENT, zoomChange),
);
return clamp(self.currentZoom * limitedZoomChange, MIN_ZOOM, MAX_ZOOM);
},
/**
* Handle zoom events from mouse wheel or trackpad pinch
* Unified smooth zoom behavior that works well for both input methods
* @param {number} val - The delta value from the wheel event
* @param {Object} mouseRelativePos - The mouse position relative to the canvas
*/
handleZoom(
val,
mouseRelativePos = { x: self.canvasSize.width / 2, y: self.canvasSize.height / 2 },
isEvent = false,
) {
if (val) {
const zoomScale = isEvent
? self.getInertialZoom(val)
: val > 0
? self.currentZoom * self.zoomBy
: self.currentZoom / self.zoomBy;
// Handle negative zoom restrictions
if (self.negativezoom !== true && zoomScale <= 1) {
self.setZoom(1);
self.setZoomPosition(0, 0);
self.updateImageAfterZoom();
return;
}
// Handle zoom out to fit or smaller
if (zoomScale <= 1) {
self.setZoom(zoomScale);
self.setZoomPosition(0, 0);
self.updateImageAfterZoom();
return;
}
// Zoom to point (mouse position) - keeps the point under the cursor in the same position
let stageScale = self.zoomScale;
const mouseAbsolutePos = {
x: (mouseRelativePos.x - self.zoomingPositionX) / stageScale,
y: (mouseRelativePos.y - self.zoomingPositionY) / stageScale,
};
self.setZoom(zoomScale);
stageScale = self.zoomScale;
const zoomingPosition = {
x: -(mouseAbsolutePos.x - mouseRelativePos.x / stageScale) * stageScale,
y: -(mouseAbsolutePos.y - mouseRelativePos.y / stageScale) * stageScale,
};
self.setZoomPosition(zoomingPosition.x, zoomingPosition.y);
self.updateImageAfterZoom();
}
},
/**
* Set mode of Image (drawing and viewing)
* @param {string} mode
*/
setMode(mode) {
self.mode = mode;
},
setImageRef(ref) {
self.imageRef = ref;
},
setContainerRef(ref) {
self.containerRef = ref;
},
setStageRef(ref) {
self.stageRef = ref;
const currentTool = self.getToolsManager().findSelectedTool();
currentTool?.updateCursor?.();
},
setOverlayRef(ref) {
self.overlayRef = ref;
},
// @todo remove
setSelected() {
// self.selectedShape = shape;
},
rotate(degree = -90) {
self.rotation = (self.rotation + degree + 360) % 360;
let ratioK = 1 / self.stageRatio;
if (self.isSideways) {
self.stageRatio = self.naturalWidth / self.naturalHeight;
} else {
self.stageRatio = 1;
}
ratioK = ratioK * self.stageRatio;
self.setZoom(self.currentZoom);
if (degree === -90) {
this.setZoomPosition(
self.zoomingPositionY * ratioK,
self.stageComponentSize.height -
self.zoomingPositionX * ratioK -
self.stageComponentSize.height * self.zoomScale,
);
}
if (degree === 90) {
this.setZoomPosition(
self.stageComponentSize.width -
self.zoomingPositionY * ratioK -
self.stageComponentSize.width * self.zoomScale,
self.zoomingPositionX * ratioK,
);
}
self.updateImageAfterZoom();
},
_recalculateImageParams() {
self.stageWidth = isFF(FF_DEV_3377)
? self.naturalWidth * self.stageZoom
: Math.round(self.naturalWidth * self.stageZoom);
self.stageHeight = isFF(FF_DEV_3377)
? self.naturalHeight * self.stageZoom
: Math.round(self.naturalHeight * self.stageZoom);
},
_updateImageSize({ width, height, userResize }) {
if (self.naturalWidth === undefined) {
return;
}
if (width > 1 && height > 1) {
const prevWidth = self.canvasSize.width;
const prevHeight = self.canvasSize.height;
const prevStageZoom = self.stageZoom;
const prevZoomScale = self.zoomScale;
self.containerWidth = width;
self.containerHeight = height;
// reinit zoom to calc stageW/H
self.setZoom(self.currentZoom);
self._recalculateImageParams();
const zoomChangeRatio = self.stageZoom / prevStageZoom;
const scaleChangeRatio = self.zoomScale / prevZoomScale;
const changeRatio = zoomChangeRatio * scaleChangeRatio;
self.setZoomPosition(
self.zoomingPositionX * changeRatio + (self.canvasSize.width / 2 - (prevWidth / 2) * changeRatio),
self.zoomingPositionY * changeRatio + (self.canvasSize.height / 2 - (prevHeight / 2) * changeRatio),
);
}
self.sizeUpdated = true;
self._updateRegionsSizes({
width: self.stageWidth,
height: self.stageHeight,
naturalWidth: self.naturalWidth,
naturalHeight: self.naturalHeight,
userResize,
});
},
_updateRegionsSizes({ width, height, naturalWidth, naturalHeight, userResize }) {
const _historyLength = self.annotation?.history?.history?.length;
self.annotation.history.freeze();
self.regions.forEach((shape) => {
shape.updateImageSize?.(width / naturalWidth, height / naturalHeight, width, height, userResize);
});
self.regs.forEach((shape) => {
shape.updateImageSize?.(width / naturalWidth, height / naturalHeight, width, height, userResize);
});
self.drawingRegion?.updateImageSize(width / naturalWidth, height / naturalHeight, width, height, userResize);
setTimeout(self.annotation.history.unfreeze, 0);
//sometimes when user zoomed in, annotation was creating a new history. This fix that in case the user has nothing in the history yet
if (_historyLength <= 1) {
// Don't force unselection of regions during the updateObjects callback from history reinit
setTimeout(() => self.annotation?.reinitHistory(false), 0);
}
},
updateImageSize(ev) {
const { naturalWidth, naturalHeight } = self.imageRef ?? ev.target;
const { offsetWidth, offsetHeight } = self.containerRef;
self.naturalWidth = naturalWidth;
self.naturalHeight = naturalHeight;
self._updateImageSize({ width: offsetWidth, height: offsetHeight });
// after regions' sizes adjustment we have to reset all saved history changes
// mobx do some batch update here, so we have to reset it asynchronously
// this happens only after initial load, so it's safe
self.setReady(true);
if (self.defaultzoom === "fit") {
self.sizeToFit();
} else {
self.sizeToAuto();
}
// Don't force unselection of regions during the updateObjects callback from history reinit
setTimeout(() => self.annotation?.reinitHistory(false), 0);
},
checkLabels() {
// there should be at least one available label or none of them should be selected
const labelStates = self.activeStates() || [];
const selectedStates = self.getAvailableStates();
return selectedStates.length !== 0 || labelStates.length === 0;
},
addShape(shape) {
self.regions.push(shape);
self.annotation.addRegion(shape);
self.setSelected(shape.id);
shape.selectRegion();
},
/**
* Resize of image canvas
* @param {*} width
* @param {*} height
*/
onResize(width, height, userResize) {
self._updateImageSize({ width, height, userResize });
},
event(name, ev, screenX, screenY) {
const [canvasX, canvasY] = self.fixZoomedCoords([screenX, screenY]);
const x = self.canvasToInternalX(canvasX);
const y = self.canvasToInternalY(canvasY);
self.getToolsManager().event(name, ev.evt || ev, x, y, canvasX, canvasY);
},
}));
const CoordsCalculations = types
.model()
.actions((self) => ({
// convert screen coords to image coords considering zoom
fixZoomedCoords([x, y]) {
if (!self.stageRef) {
return [x, y];
}
// good official way, but maybe a bit slower and with repeating cloning
const p = self.stageRef.getAbsoluteTransform().copy().invert().point({ x, y });
return [p.x, p.y];
},
// convert image coords to screen coords considering zoom
zoomOriginalCoords([x, y]) {
const p = self.stageRef.getAbsoluteTransform().point({ x, y });
return [p.x, p.y];
},
/**
* @typedef {number[]|{ x: number, y: number }} Point
*/
/**
* @callback PointFn
* @param {Point} point
* @returns Point
*/
/**
* Wrap point operations to convert zoomed coords from screen to image and back
* Good for event handlers, receiving screen coords, but working with image coords
* Accepts both [x, y] and {x, y} points; preserves this format
* @param {PointFn} fn wrapped function do some math with image coords
* @return {PointFn} outer function do some math with screen coords
*/
fixForZoom(fn) {
return (p) => this.fixForZoomWrapper(p, fn);
},
fixForZoomWrapper(p, fn) {
const asArray = p.x === undefined;
const [x, y] = self.fixZoomedCoords(asArray ? p : [p.x, p.y]);
const modified = fn(asArray ? [x, y] : { x, y });
const zoomed = self.zoomOriginalCoords(asArray ? modified : [modified.x, modified.y]);
return asArray ? zoomed : { x: zoomed[0], y: zoomed[1] };
},
}))
// putting this transforms to views forces other getters to be recalculated on resize
.views((self) => ({
// helps to calculate rotation because internal coords are square and real one usually aren't
get whRatio() {
// don't need this for absolute coords
if (!isFF(FF_DEV_3793)) return 1;
return self.stageWidth / self.stageHeight;
},
// @todo scale?
canvasToInternalX(n) {
return (n / self.stageWidth) * RELATIVE_STAGE_WIDTH;
},
canvasToInternalY(n) {
return (n / self.stageHeight) * RELATIVE_STAGE_HEIGHT;
},
internalToCanvasX(n) {
return (n / RELATIVE_STAGE_WIDTH) * self.stageWidth;
},
internalToCanvasY(n) {
return (n / RELATIVE_STAGE_HEIGHT) * self.stageHeight;
},
internalToImageX(n) {
const { naturalWidth } = self.currentImageEntity;
return (n / RELATIVE_STAGE_WIDTH) * naturalWidth;
},
internalToImageY(n) {
const { naturalHeight } = self.currentImageEntity;
return (n / RELATIVE_STAGE_HEIGHT) * naturalHeight;
},
imageToInternalX(n) {
const { naturalWidth } = self.currentImageEntity;
return (n / naturalWidth) * RELATIVE_STAGE_WIDTH;
},
imageToInternalY(n) {
const { naturalHeight } = self.currentImageEntity;
return (n / naturalHeight) * RELATIVE_STAGE_HEIGHT;
},
}));
// mock coords calculations to transparently pass coords with FF 3793 off
const AbsoluteCoordsCalculations = CoordsCalculations.views(() => ({
canvasToInternalX(n) {
return n;
},
canvasToInternalY(n) {
return n;
},
internalToCanvasX(n) {
return n;
},
internalToCanvasY(n) {
return n;
},
}));
const ImageModel = types.compose(
"ImageModel",
TagAttrs,
ObjectBase,
...(isFF(FF_LSDV_4583) ? [MultiItemObjectBase] : []),
AnnotationMixin,
IsReadyWithDepsMixin,
ImageEntityMixin,
Model,
isFF(FF_DEV_3793) ? CoordsCalculations : AbsoluteCoordsCalculations,
);
const HtxImage = inject("store")(ImageView);
Registry.addTag("image", ImageModel, HtxImage);
Registry.addObjectType(ImageModel);
export { ImageModel, HtxImage };