import { types } from "mobx-state-tree"; import Utils from "../utils"; import throttle from "lodash/throttle"; import { MIN_SIZE } from "../tools/Base"; import { FF_DEV_3391, FF_DEV_3793, isFF } from "../utils/feature-flags"; import { ff } from "@humansignal/core"; import { RELATIVE_STAGE_HEIGHT, RELATIVE_STAGE_WIDTH } from "../components/ImageView/Image"; const DrawingTool = types .model("DrawingTool", { default: true, mode: types.optional(types.enumeration(["drawing", "viewing"]), "viewing"), unselectRegionOnToolChange: true, isDrawingTool: true, }) .volatile(() => { return { currentArea: null, }; }) .views((self) => { return { createRegionOptions(opts) { return { ...opts, coordstype: "px", }; }, get tagTypes() { console.error("Drawing tool model needs to implement tagTypes getter in views"); return {}; }, isIncorrectControl() { return self.tagTypes.stateTypes === self.control.type && !self.control.isSelected; }, isIncorrectLabel() { return !self.obj.checkLabels(); }, get isDrawing() { return self.mode === "drawing"; }, get getActiveShape() { return self.currentArea; }, getCurrentArea() { return self.currentArea; }, current() { return self.currentArea; }, canStart() { return !self.isDrawing && !self.annotation.isReadOnly(); }, get defaultDimensions() { console.warn("Drawing tool model needs to implement defaultDimentions getter in views"); return {}; }, get MIN_SIZE() { if (isFF(FF_DEV_3793)) { return { X: (MIN_SIZE.X / self.obj.stageScale / self.obj.stageWidth) * RELATIVE_STAGE_WIDTH, Y: (MIN_SIZE.Y / self.obj.stageScale / self.obj.stageHeight) * RELATIVE_STAGE_HEIGHT, }; } return { X: MIN_SIZE.X / self.obj.stageScale, Y: MIN_SIZE.Y / self.obj.stageScale, }; }, /** * Determines if an interaction is allowed based on the current context and event properties. * * @param {Object} ev - The event object containing details about the interaction. * @return {boolean} Returns true if the interaction is allowed, otherwise false. */ isAllowedInteraction(ev) { if (isFF(FF_DEV_3391) && !self.annotation.editable) { return false; } if (self.group !== "segmentation") return true; if (ev.offsetX > self.obj.canvasSize.width) return false; if (ev.offsetY > self.obj.canvasSize.height) return false; return true; }, }; }) .actions((self) => { let lastClick = { ts: 0, x: 0, y: 0, }; return { event(name, ev, [x, y, canvasX, canvasY]) { // filter right clicks and middle clicks and shift pressed if (ev.button > 0 || ev.shiftKey) return; let fn = `${name}Ev`; if (typeof self[fn] !== "undefined") self[fn].call(self, ev, [x, y], [canvasX, canvasY]); // Emulating of dblclick event, 'cause redrawing will crush the the original one if (name === "click") { const ts = ev.timeStamp; if (ts - lastClick.ts < 300 && self.comparePointsWithThreshold(lastClick, { x, y })) { fn = `dbl${fn}`; if (typeof self[fn] !== "undefined") self[fn].call(self, ev, [x, y], [canvasX, canvasY]); } lastClick = { ts, x, y }; } }, comparePointsWithThreshold(p1, p2, threshold = { x: self.MIN_SIZE.X, y: self.MIN_SIZE.Y }) { if (!p1 || !p2) return; if (typeof threshold === "number") threshold = { x: threshold, y: threshold }; return Math.abs(p1.x - p2.x) < threshold.x && Math.abs(p1.y - p2.y) < threshold.y; }, }; }) .actions((self) => { return { createDrawingRegion(opts) { const control = self.control; const resultValue = control.getResultValue(); self.currentArea = self.obj.createDrawingRegion(opts, resultValue, control, false); self.currentArea.setDrawing(true); self.applyActiveStates(self.currentArea); self.annotation.setIsDrawing(true); return self.currentArea; }, resumeUnfinishedRegion(existingUnclosedPolygon) { self.currentArea = existingUnclosedPolygon; self.currentArea.setDrawing(true); self.annotation.regionStore.selection._updateResultsFromRegions([self.currentArea]); self.mode = "drawing"; self.annotation.setIsDrawing(true); self.annotation.regionStore.selection.drawingSelect(self.currentArea); self.listenForClose?.(); if (self.manager.findSelectedTool() !== self) { self.manager.selectTool(self, true); } }, commitDrawingRegion() { const { currentArea, control, obj } = self; if (!currentArea) return; const source = currentArea.toJSON(); const value = Object.keys(currentArea.serialize().value).reduce( (value, key) => { value[key] = source[key]; return value; }, { coordstype: "px", dynamic: self.dynamic, converted: true }, ); const [main, ...rest] = currentArea.results; const newArea = self.annotation.createResult(value, main.value.toJSON(), control, obj); //when user is using two different labels tag to draw a region, the other labels will be added to the region rest.forEach((r) => newArea.addResult(r.toJSON())); currentArea.setDrawing(false); self.deleteRegion(); newArea.notifyDrawingFinished(); return newArea; }, createRegion(opts, skipAfterCreate = false) { const control = self.control; const resultValue = control.getResultValue(); const activeStates = self.obj.activeStates(); // Remove the main control from additional states to avoid duplication const additionalStates = activeStates.filter((state) => state !== control); if (ff.isActive(ff.FF_MULTIPLE_LABELS_REGIONS)) { self.currentArea = self.annotation.createResult( opts, resultValue, control, self.obj, skipAfterCreate, additionalStates, ); } else { self.currentArea = self.annotation.createResult(opts, resultValue, control, self.obj, skipAfterCreate); self.applyActiveStates(self.currentArea); } return self.currentArea; }, deleteRegion() { self.currentArea = null; self.obj.deleteDrawingRegion(); }, applyActiveStates(area) { const activeStates = self.obj.activeStates(); activeStates.forEach((state) => { area.setValue(state); }); }, beforeCommitDrawing() { return true; }, canStartDrawing() { return ( !self.disabled && !self.isIncorrectControl() && !self.isIncorrectLabel() && self.canStart() && !self.annotation.isDrawing ); }, startDrawing(x, y) { self.annotation.history.freeze(); self.mode = "drawing"; self.currentArea = self.createDrawingRegion(self.createRegionOptions({ x, y })); }, finishDrawing() { if (!self.beforeCommitDrawing()) { self.deleteRegion(); if (self.control.type === self.tagTypes.stateTypes) self.annotation.unselectAll(true); self._resetState(); } else { self._finishDrawing(); } }, _finishDrawing() { self.commitDrawingRegion(); self._resetState(); }, _resetState() { self.annotation.setIsDrawing(false); self.annotation.history.unfreeze(); self.mode = "viewing"; }, }; }); const TwoPointsDrawingTool = DrawingTool.named("TwoPointsDrawingTool") .views((self) => ({ get defaultDimensions() { return { width: self.MIN_SIZE.X, height: self.MIN_SIZE.Y, }; }, })) .actions((self) => { const DEFAULT_MODE = 0; const DRAG_MODE = 1; const TWO_CLICKS_MODE = 2; let currentMode = DEFAULT_MODE; let modeAfterMouseMove = DEFAULT_MODE; let startPoint = null; let endPoint = { x: 0, y: 0 }; const Super = { finishDrawing: self.finishDrawing, }; return { updateDraw: throttle((x, y) => { if (currentMode === DEFAULT_MODE) return; self.draw(x, y); }, 48), // 3 frames, optimized enough and not laggy yet draw(x, y) { const shape = self.getCurrentArea(); if (!shape) return; const isEllipse = shape.type.includes("ellipse"); const maxStageWidth = isFF(FF_DEV_3793) ? RELATIVE_STAGE_WIDTH : self.obj.stageWidth; const maxStageHeight = isFF(FF_DEV_3793) ? RELATIVE_STAGE_HEIGHT : self.obj.stageHeight; let { x1, y1, x2, y2 } = isEllipse ? { x1: shape.startX, y1: shape.startY, x2: x, y2: y, } : Utils.Image.reverseCoordinates({ x: shape.startX, y: shape.startY }, { x, y }); x1 = Math.max(0, x1); y1 = Math.max(0, y1); x2 = Math.min(maxStageWidth, x2); y2 = Math.min(maxStageHeight, y2); let [distX, distY] = [x2 - x1, y2 - y1].map(Math.abs); if (isEllipse) { distX = Math.min(distX, Math.min(x1, maxStageWidth - x1)); distY = Math.min(distY, Math.min(y1, maxStageHeight - y1)); } shape.setPositionInternal(x1, y1, distX, distY, shape.rotation); }, finishDrawing(x, y) { startPoint = null; Super.finishDrawing(x, y); currentMode = DEFAULT_MODE; modeAfterMouseMove = DEFAULT_MODE; }, mousedownEv(ev, [x, y]) { if (!self.canStartDrawing()) return; if (!self.isAllowedInteraction(ev)) return; startPoint = { x, y }; if (currentMode === DEFAULT_MODE) { modeAfterMouseMove = DRAG_MODE; } }, mousemoveEv(_, [x, y]) { if (currentMode === DEFAULT_MODE && startPoint) { if (!self.comparePointsWithThreshold(startPoint, { x, y })) { currentMode = modeAfterMouseMove; if ([DRAG_MODE, TWO_CLICKS_MODE].includes(currentMode)) { self.startDrawing(startPoint.x, startPoint.y); if (!self.isDrawing) { currentMode = DEFAULT_MODE; return; } } } } if (!self.isDrawing) return; if ([DRAG_MODE, TWO_CLICKS_MODE].includes(currentMode)) { self.updateDraw(x, y); } }, mouseupEv(_, [x, y]) { if (currentMode !== DRAG_MODE) return; endPoint = { x, y }; if (!self.isDrawing) return; self.draw(x, y); self.finishDrawing(x, y); }, clickEv(ev, [x, y]) { if (!self.canStartDrawing()) return; if (!self.isAllowedInteraction(ev)) return; // @todo: here is a potential problem with endPoint // it may be incorrect due to it may be not set at this moment if (startPoint && endPoint && !self.comparePointsWithThreshold(startPoint, endPoint)) return; if (currentMode === DEFAULT_MODE) { modeAfterMouseMove = TWO_CLICKS_MODE; } else if (self.isDrawing && currentMode === TWO_CLICKS_MODE) { self.draw(x, y); self.finishDrawing(x, y); currentMode = DEFAULT_MODE; } }, dblclickEv(ev, [x, y]) { if (!self.canStartDrawing()) return; if (!self.isAllowedInteraction(ev)) return; let dX = self.defaultDimensions.width; let dY = self.defaultDimensions.height; if (isFF(FF_DEV_3793)) { dX = self.obj.canvasToInternalX(dX); dY = self.obj.canvasToInternalY(dY); } if (currentMode === DEFAULT_MODE) { self.startDrawing(x, y); if (!self.isDrawing) return; x += dX; y += dY; self.draw(x, y); self.finishDrawing(x, y); } }, }; }); const MultipleClicksDrawingTool = DrawingTool.named("MultipleClicksMixin") .views(() => ({ canStart() { return !this.current(); }, })) .actions((self) => { let startPoint = { x: 0, y: 0 }; let pointsCount = 0; let lastPoint = { x: -1, y: -1 }; let lastEvent = 0; const MOUSE_DOWN_EVENT = 1; const MOUSE_UP_EVENT = 2; const CLICK_EVENT = 3; let lastClickTs = 0; const Super = { canStartDrawing: self.canStartDrawing, }; return { canStartDrawing() { return Super.canStartDrawing() && !self.annotation.regionStore.hasSelection; }, nextPoint(x, y) { const area = self.getCurrentArea(); const object = self.obj; if (area && object && object.multiImage && area.item_index !== object.currentImage) return; self.getCurrentArea().addPoint(x, y); pointsCount++; }, listenForClose() { console.error("MultipleClicksMixin model needs to implement listenForClose method in actions"); }, closeCurrent() { console.error("MultipleClicksMixin model needs to implement closeCurrent method in actions"); }, finishDrawing() { if (!self.isDrawing) return; self.annotation.regionStore.selection.drawingUnselect(); pointsCount = 0; self.closeCurrent(); setTimeout(() => { self._finishDrawing(); }); }, cleanupUncloseableShape() { self.deleteRegion(); if (self.control.type === self.tagTypes.stateTypes) self.annotation.unselectAll(true); self._resetState(); }, mousedownEv(ev, [x, y]) { if (!self.isAllowedInteraction(ev)) return; lastPoint = { x, y }; lastEvent = MOUSE_DOWN_EVENT; }, mouseupEv(ev, [x, y]) { if (lastEvent === MOUSE_DOWN_EVENT && self.comparePointsWithThreshold(lastPoint, { x, y })) { self._clickEv(ev, [x, y]); lastEvent = MOUSE_UP_EVENT; } lastPoint = { x: -1, y: -1 }; }, clickEv(ev, [x, y]) { if (lastEvent !== MOUSE_UP_EVENT) { self._clickEv(ev, [x, y]); } lastEvent = CLICK_EVENT; lastPoint = { x: -1, y: -1 }; }, _clickEv(ev, [x, y]) { if (!self.isAllowedInteraction(ev)) return; if (self.current()) { if ( pointsCount === 1 && self.comparePointsWithThreshold(startPoint, { x, y }) && ev.timeStamp - lastClickTs < 350 ) { // dblclick self.drawDefault(); } else { if (self.comparePointsWithThreshold(startPoint, { x, y })) { if (pointsCount > 2) { self.finishDrawing(); } } else { self.nextPoint(x, y); } } } else { if (!self.canStartDrawing()) return; startPoint = { x, y }; pointsCount = 1; lastClickTs = ev.timeStamp; self.startDrawing(x, y); self.listenForClose(); } }, drawDefault() { const { x, y } = startPoint; let dX = self.defaultDimensions.length; let dY = self.defaultDimensions.length; if (isFF(FF_DEV_3793)) { dX = self.obj.canvasToInternalX(dX); dY = self.obj.canvasToInternalY(dY); } self.nextPoint(x + dX, y); self.nextPoint(x + dX / 2, y + Math.sin(Math.PI / 3) * dY); self.finishDrawing(); }, }; }); const ThreePointsDrawingTool = DrawingTool.named("ThreePointsDrawingTool") .views((self) => ({ canStart() { return !this.current(); }, get defaultDimensions() { return { width: self.MIN_SIZE.X, height: self.MIN_SIZE.Y, }; }, })) .actions((self) => { let points = []; let lastEvent = 0; const DEFAULT_MODE = 0; const MOUSE_DOWN_EVENT = 1; const MOUSE_UP_EVENT = 2; const CLICK_EVENT = 3; const DRAG_MODE = 4; const DBL_CLICK_EVENT = 5; let currentMode = DEFAULT_MODE; let startPoint = null; const Super = { finishDrawing: self.finishDrawing, }; return { canStartDrawing() { return !self.isIncorrectControl(); }, updateDraw: (x, y) => { if (currentMode === DEFAULT_MODE) self.getCurrentArea()?.draw(x, y, points); else if (currentMode === DRAG_MODE) self.draw(x, y); }, nextPoint(x, y) { points.push({ x, y }); self.getCurrentArea().draw(x, y, points); }, draw(x, y) { const shape = self.getCurrentArea(); if (!shape) return; const maxStageWidth = isFF(FF_DEV_3793) ? RELATIVE_STAGE_WIDTH : self.obj.stageWidth; const maxStageHeight = isFF(FF_DEV_3793) ? RELATIVE_STAGE_HEIGHT : self.obj.stageHeight; let { x1, y1, x2, y2 } = Utils.Image.reverseCoordinates({ x: shape.startX, y: shape.startY }, { x, y }); x1 = Math.max(0, x1); y1 = Math.max(0, y1); x2 = Math.min(maxStageWidth, x2); y2 = Math.min(maxStageHeight, y2); shape.setPositionInternal(x1, y1, x2 - x1, y2 - y1, shape.rotation); }, finishDrawing(x, y) { if (self.isDrawing) { points = []; startPoint = null; currentMode = DEFAULT_MODE; Super.finishDrawing(x, y); setTimeout(() => { self._finishDrawing(); }); } else return; }, mousemoveEv(_, [x, y]) { if (self.isDrawing) { if (lastEvent === MOUSE_DOWN_EVENT) { currentMode = DRAG_MODE; } if (currentMode === DRAG_MODE && startPoint) { self.startDrawing(startPoint.x, startPoint.y); self.updateDraw(x, y); } else if (currentMode === DEFAULT_MODE) { self.updateDraw(x, y); } } }, mousedownEv(ev, [x, y]) { if (!self.canStartDrawing() || self.annotation.isDrawing) return; if (!self.isAllowedInteraction(ev)) return; lastEvent = MOUSE_DOWN_EVENT; startPoint = { x, y }; self.mode = "drawing"; }, mouseupEv(_ev, [x, y]) { if (!self.canStartDrawing()) return; if (self.isDrawing) { if (currentMode === DRAG_MODE) { self.draw(x, y); self.finishDrawing(x, y); } lastEvent = MOUSE_UP_EVENT; } }, clickEv(ev, [x, y]) { if (!self.canStartDrawing()) return; if (!self.isAllowedInteraction(ev)) return; if (currentMode === DEFAULT_MODE) { self._clickEv(ev, [x, y]); } lastEvent = CLICK_EVENT; }, _clickEv(_ev, [x, y]) { if (points.length >= 2) { self.finishDrawing(x, y); } else if (points.length === 0) { points = [{ x, y }]; self.startDrawing(x, y); } else { self.nextPoint(x, y); } }, dblclickEv(ev, [x, y]) { lastEvent = DBL_CLICK_EVENT; if (!self.canStartDrawing()) return; if (!self.isAllowedInteraction(ev)) return; let dX = self.defaultDimensions.width; let dY = self.defaultDimensions.height; if (isFF(FF_DEV_3793)) { dX = self.obj.canvasToInternalX(dX); dY = self.obj.canvasToInternalY(dY); } if (currentMode === DEFAULT_MODE) { self.startDrawing(x, y); if (!self.isDrawing) return; x += dX; y += dY; self.draw(x, y); self.finishDrawing(x, y); } }, }; }); export { DrawingTool, TwoPointsDrawingTool, MultipleClicksDrawingTool, ThreePointsDrawingTool };