diff --git a/src/drawing/DrawingAnnotations.tsx b/src/drawing/DrawingAnnotations.tsx index e2e1336e8..c5b1f12cc 100644 --- a/src/drawing/DrawingAnnotations.tsx +++ b/src/drawing/DrawingAnnotations.tsx @@ -24,6 +24,7 @@ export type Props = { location: number; redoDrawingPathGroup: () => void; resetDrawing: () => void; + rotation: number; setActiveAnnotationId: (annotationId: string | null) => void; setReferenceId: (uuid: string) => void; setStaged: (staged: CreatorItemDrawing | null) => void; @@ -47,6 +48,7 @@ const DrawingAnnotations = (props: Props): JSX.Element => { location, redoDrawingPathGroup, resetDrawing, + rotation, setActiveAnnotationId, setReferenceId, setStaged, @@ -170,6 +172,7 @@ const DrawingAnnotations = (props: Props): JSX.Element => { color={color} onStart={handleStart} onStop={handleStop} + rotation={rotation} /> )} diff --git a/src/drawing/DrawingAnnotationsContainer.tsx b/src/drawing/DrawingAnnotationsContainer.tsx index 1c51d443d..743695685 100644 --- a/src/drawing/DrawingAnnotationsContainer.tsx +++ b/src/drawing/DrawingAnnotationsContainer.tsx @@ -18,6 +18,7 @@ import { getAnnotationsForLocation, getColor, getCreatorStatus, + getRotation, Mode, setActiveAnnotationIdAction, setReferenceIdAction, @@ -34,6 +35,7 @@ export type Props = { canShowPopupToolbar: boolean; drawnPathGroups: Array; isCreating: boolean; + rotation: number; stashedPathGroups: Array; }; @@ -47,6 +49,7 @@ export const mapStateToProps = (state: AppState, { location }: { location: numbe canShowPopupToolbar: creatorStatus === CreatorStatus.started, drawnPathGroups: getDrawingDrawnPathGroupsForLocation(state, location), isCreating: getAnnotationMode(state) === Mode.DRAWING && creatorStatus !== CreatorStatus.pending, + rotation: getRotation(state), stashedPathGroups: getStashedDrawnPathGroupsForLocation(state, location), }; }; diff --git a/src/drawing/DrawingCreator.tsx b/src/drawing/DrawingCreator.tsx index 3343fcdee..dbd598d74 100644 --- a/src/drawing/DrawingCreator.tsx +++ b/src/drawing/DrawingCreator.tsx @@ -7,6 +7,7 @@ import DrawingSVG, { DrawingSVGRef } from './DrawingSVG'; import PointerCapture, { PointerCaptureRef, Status as DrawingStatus } from '../components/PointerCapture'; import { getDrawingCursor } from './DrawingCursor'; import { getPathCommands } from './drawingUtil'; +import { getElementLocalPosition } from '../utils/rotate'; import { PathGroup, Position } from '../@types'; import './DrawingCreator.scss'; @@ -15,6 +16,7 @@ export type Props = { color?: string; onStart: () => void; onStop: (pathGroup: PathGroup) => void; + rotation?: number; size?: number; }; @@ -26,6 +28,7 @@ export default function DrawingCreator({ color = defaultStrokeColor, onStart, onStop, + rotation = 0, size = defaultStrokeSize, }: Props): JSX.Element { const [drawingStatus, setDrawingStatus] = React.useState(DrawingStatus.init); @@ -45,7 +48,9 @@ export default function DrawingCreator({ return []; } - const { height, width } = creatorEl.getBoundingClientRect(); + // Get the element's dimensions (before any rotation is applied) + const width = creatorEl.offsetWidth; + const height = creatorEl.offsetHeight; const { size: minSize } = stroke; const MAX_X = width - minSize; const MAX_Y = height - minSize; @@ -56,21 +61,9 @@ export default function DrawingCreator({ })); }, [stroke]); - const getPosition = (x: number, y: number): [number, number] => { - const { current: creatorEl } = creatorElRef; - - if (!creatorEl) { - return [x, y]; - } - - // Calculate the new position based on the mouse position less the page offset - const { left, top } = creatorEl.getBoundingClientRect(); - return [x - left, y - top]; - }; - // Drawing Lifecycle Callbacks const startDraw = (x: number, y: number): void => { - const [x1, y1] = getPosition(x, y); + const [x1, y1] = getElementLocalPosition(x, y, creatorElRef.current, rotation); setDrawingStatus(DrawingStatus.dragging); @@ -100,7 +93,7 @@ export default function DrawingCreator({ const updateDraw = React.useCallback( (x: number, y: number): void => { - const [x2, y2] = getPosition(x, y); + const [x2, y2] = getElementLocalPosition(x, y, creatorElRef.current, rotation); const { current: points } = capturedPointsRef; points.push({ x: x2, y: y2 }); @@ -111,7 +104,7 @@ export default function DrawingCreator({ onStart(); } }, - [drawingStatus, onStart, setDrawingStatus], + [drawingStatus, onStart, rotation, setDrawingStatus], ); // Event Handlers diff --git a/src/drawing/__tests__/DrawingAnnotations-test.tsx b/src/drawing/__tests__/DrawingAnnotations-test.tsx index 5e89ecf1a..9146f0673 100644 --- a/src/drawing/__tests__/DrawingAnnotations-test.tsx +++ b/src/drawing/__tests__/DrawingAnnotations-test.tsx @@ -40,6 +40,7 @@ describe('DrawingAnnotations', () => { location: 0, redoDrawingPathGroup: jest.fn(), resetDrawing: jest.fn(), + rotation: 0, setActiveAnnotationId: jest.fn(), setReferenceId: jest.fn(), setStaged: jest.fn(), diff --git a/src/drawing/__tests__/DrawingCreator-test.tsx b/src/drawing/__tests__/DrawingCreator-test.tsx index e85199995..f52c936e4 100644 --- a/src/drawing/__tests__/DrawingCreator-test.tsx +++ b/src/drawing/__tests__/DrawingCreator-test.tsx @@ -25,6 +25,9 @@ describe('DrawingCreator', () => { const getWrapper = (props?: Partial): ReactWrapper> => mount(); + let origOffsetWidth: PropertyDescriptor | undefined; + let origOffsetHeight: PropertyDescriptor | undefined; + beforeEach(() => { jest.useFakeTimers(); @@ -32,6 +35,16 @@ describe('DrawingCreator', () => { jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => setTimeout(cb, 100)); // 10 fps; jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => getDOMRect()); jest.spyOn(Element.prototype, 'setAttribute'); + + origOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth'); + origOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 100 }); + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 100 }); + }); + + afterEach(() => { + if (origOffsetWidth) Object.defineProperty(HTMLElement.prototype, 'offsetWidth', origOffsetWidth); + if (origOffsetHeight) Object.defineProperty(HTMLElement.prototype, 'offsetHeight', origOffsetHeight); }); const simulateDrawStart = (wrapper: ReactWrapper>, clientX = 10, clientY = 10): void => { diff --git a/src/region/RegionCreation.tsx b/src/region/RegionCreation.tsx index 4556d50b3..b958d27fd 100644 --- a/src/region/RegionCreation.tsx +++ b/src/region/RegionCreation.tsx @@ -9,9 +9,9 @@ import { getVideoCurrentTimeInMilliseconds } from '../utils/useVideoTiming'; type Props = { isCreating: boolean; - isRotated: boolean; location: number; resetCreator: () => void; + rotation: number; setReferenceId: (uuid: string) => void; setStaged: (staged: CreatorItemRegion | null) => void; setStatus: (status: CreatorStatus) => void; @@ -27,7 +27,7 @@ type State = { export default class RegionCreation extends React.PureComponent { static defaultProps = { isCreating: false, - isRotated: false, + rotation: 0, }; state: State = {}; @@ -57,11 +57,7 @@ export default class RegionCreation extends React.PureComponent { }; render(): JSX.Element | null { - const { isCreating, isRotated, staged } = this.props; - - if (isRotated) { - return null; - } + const { isCreating, rotation, staged } = this.props; return ( <> @@ -71,6 +67,7 @@ export default class RegionCreation extends React.PureComponent { onAbort={this.handleAbort} onStart={this.handleStart} onStop={this.handleStop} + rotation={rotation} /> )} diff --git a/src/region/RegionCreationContainer.tsx b/src/region/RegionCreationContainer.tsx index 826521750..a1e34fa72 100644 --- a/src/region/RegionCreationContainer.tsx +++ b/src/region/RegionCreationContainer.tsx @@ -19,7 +19,7 @@ import withProviders from '../common/withProviders'; export type Props = { isCreating: boolean; - isRotated: boolean; + rotation: number; staged: CreatorItemRegion | null; }; @@ -28,7 +28,7 @@ export const mapStateToProps = (state: AppState, { location }: { location: numbe return { isCreating: getAnnotationMode(state) === Mode.REGION && getCreatorStatus(state) !== CreatorStatus.pending, - isRotated: !!getRotation(state), + rotation: getRotation(state), staged: isCreatorStagedRegion(staged) ? staged : null, }; }; diff --git a/src/region/RegionCreator.tsx b/src/region/RegionCreator.tsx index 26ab51285..e21869011 100644 --- a/src/region/RegionCreator.tsx +++ b/src/region/RegionCreator.tsx @@ -4,6 +4,7 @@ import PointerCapture, { Status as DrawingStatus } from '../components/PointerCa import PopupCursor from '../components/Popups/PopupCursor'; import RegionRect, { RegionRectRef } from './RegionRect'; import useAutoScroll from '../common/useAutoScroll'; +import { getElementLocalPosition } from '../utils/rotate'; import { Rect } from '../@types'; import { styleShape } from './regionUtil'; import './RegionCreator.scss'; @@ -13,6 +14,7 @@ type Props = { onAbort: () => void; onStart: () => void; onStop: (shape: Rect) => void; + rotation?: number; }; const MIN_X = 0; // Minimum region x position must be positive @@ -22,7 +24,7 @@ const MIN_SIZE = 10; // Minimum region size must be large enough to be clickable const isValid = (x1: number, y1: number, x2: number, y2: number): boolean => Math.abs(x2 - x1) >= MIN_SIZE || Math.abs(y2 - y1) >= MIN_SIZE; -export default function RegionCreator({ className, onAbort, onStart, onStop }: Props): JSX.Element { +export default function RegionCreator({ className, onAbort, onStart, onStop, rotation = 0 }: Props): JSX.Element { const [drawingStatus, setDrawingStatus] = React.useState(DrawingStatus.init); const [isHovering, setIsHovering] = React.useState(false); const creatorElRef = React.useRef(null); @@ -34,18 +36,6 @@ export default function RegionCreator({ className, onAbort, onStart, onStop }: P const regionRectRef = React.useRef(null); const renderHandleRef = React.useRef(null); - // Drawing Helpers - const getPosition = (x: number, y: number): [number, number] => { - const { current: creatorEl } = creatorElRef; - - if (!creatorEl) { - return [x, y]; - } - - // Calculate the new position based on the mouse position less the page offset - const { left, top } = creatorEl.getBoundingClientRect(); - return [x - left, y - top]; - }; const getShape = (): Rect | null => { const { current: creatorEl } = creatorElRef; const { current: x1 } = positionX1Ref; @@ -57,7 +47,9 @@ export default function RegionCreator({ className, onAbort, onStart, onStop }: P return null; } - const { height, width } = creatorEl.getBoundingClientRect(); + // Get the element's dimensions (before any rotation is applied) + const width = creatorEl.offsetWidth; + const height = creatorEl.offsetHeight; const MAX_HEIGHT = height - MIN_SIZE; const MAX_WIDTH = width - MIN_SIZE; const MAX_X = Math.max(0, width - MIN_X); @@ -88,7 +80,7 @@ export default function RegionCreator({ className, onAbort, onStart, onStop }: P // Drawing Lifecycle Callbacks const startDraw = (x: number, y: number): void => { - const [x1, y1] = getPosition(x, y); + const [x1, y1] = getElementLocalPosition(x, y, creatorElRef.current, rotation); setDrawingStatus(DrawingStatus.dragging); @@ -119,7 +111,7 @@ export default function RegionCreator({ className, onAbort, onStart, onStop }: P const updateDraw = React.useCallback( (x: number, y: number): void => { - const [x2, y2] = getPosition(x, y); + const [x2, y2] = getElementLocalPosition(x, y, creatorElRef.current, rotation); const { current: x1 } = positionX1Ref; const { current: y1 } = positionY1Ref; const { current: prevX2 } = positionX2Ref; @@ -138,7 +130,7 @@ export default function RegionCreator({ className, onAbort, onStart, onStop }: P onStart(); } }, - [drawingStatus, onStart, setDrawingStatus], + [drawingStatus, onStart, rotation, setDrawingStatus], ); // Event Handlers diff --git a/src/region/__tests__/RegionCreation-test.tsx b/src/region/__tests__/RegionCreation-test.tsx index ddc862a1a..4aaefe240 100644 --- a/src/region/__tests__/RegionCreation-test.tsx +++ b/src/region/__tests__/RegionCreation-test.tsx @@ -136,15 +136,15 @@ describe('RegionCreation', () => { expect(wrapper.exists(RegionRect)).toBe(false); }); - test('should not render creation components if file is rotated', () => { + test('should render creation components when file is rotated', () => { const wrapper = getWrapper({ isCreating: true, - isRotated: true, - staged: {}, + rotation: -90, + staged: getStaged(), }); - expect(wrapper.exists(RegionCreator)).toBe(false); - expect(wrapper.exists(RegionRect)).toBe(false); + expect(wrapper.exists(RegionCreator)).toBe(true); + expect(wrapper.exists(RegionRect)).toBe(true); }); }); }); diff --git a/src/region/__tests__/RegionCreationContainer-test.tsx b/src/region/__tests__/RegionCreationContainer-test.tsx index 9cfe99f8a..d4a692f05 100644 --- a/src/region/__tests__/RegionCreationContainer-test.tsx +++ b/src/region/__tests__/RegionCreationContainer-test.tsx @@ -70,20 +70,22 @@ describe('RegionCreationContainer', () => { ); test.each` - rotation | isRotated - ${null} | ${false} - ${undefined} | ${false} - ${0} | ${false} - ${90} | ${true} - ${360} | ${true} - ${-360} | ${true} - ${-90} | ${true} - ${-0} | ${false} - `('should set the isRotated prop based on the rotation angle value', ({ isRotated, rotation }) => { + rotation | expected + ${null} | ${null} + ${undefined} | ${0} + ${0} | ${0} + ${-0} | ${-0} + ${90} | ${90} + ${-90} | ${-90} + ${-180} | ${-180} + ${-270} | ${-270} + ${360} | ${360} + ${-360} | ${-360} + `('should pass rotation=$rotation as $expected to the underlying component', ({ rotation, expected }) => { const store = createStore({ options: { rotation } }); const wrapper = getWrapper({ store }); - expect(wrapper.find(RegionCreation).prop('isRotated')).toEqual(isRotated); + expect(wrapper.find(RegionCreation).prop('rotation')).toEqual(expected); }); }); }); diff --git a/src/region/__tests__/RegionCreator-test.tsx b/src/region/__tests__/RegionCreator-test.tsx index 54324770e..639b576b5 100644 --- a/src/region/__tests__/RegionCreator-test.tsx +++ b/src/region/__tests__/RegionCreator-test.tsx @@ -34,6 +34,9 @@ describe('RegionCreator', () => { // Render helpers const getWrapper = (props = {}): ReactWrapper => mount(); + let origOffsetWidth: PropertyDescriptor | undefined; + let origOffsetHeight: PropertyDescriptor | undefined; + beforeEach(() => { jest.useFakeTimers(); @@ -42,6 +45,16 @@ describe('RegionCreator', () => { jest.spyOn(document, 'removeEventListener'); jest.spyOn(window, 'cancelAnimationFrame'); jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => setTimeout(cb, 100)); // 10 fps + + origOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth'); + origOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 1000 }); + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 1000 }); + }); + + afterEach(() => { + if (origOffsetWidth) Object.defineProperty(HTMLElement.prototype, 'offsetWidth', origOffsetWidth); + if (origOffsetHeight) Object.defineProperty(HTMLElement.prototype, 'offsetHeight', origOffsetHeight); }); describe('mouse events', () => { diff --git a/src/utils/__tests__/rotate-test.ts b/src/utils/__tests__/rotate-test.ts index ff956a95f..c92cfe0c1 100644 --- a/src/utils/__tests__/rotate-test.ts +++ b/src/utils/__tests__/rotate-test.ts @@ -1,4 +1,4 @@ -import { getPoints, getRotatedShape, selectTransformationPoint } from '../../utils/rotate'; +import { getElementLocalPosition, getPoints, getRotatedShape, selectTransformationPoint } from '../../utils/rotate'; describe('rotate', () => { const parseValue = (value: number): number => parseFloat(value.toFixed(3)); @@ -34,6 +34,60 @@ describe('rotate', () => { ); }); + describe('getElementLocalPosition()', () => { + const createElement = (width: number, height: number, rect: Partial = {}): HTMLElement => { + const el = document.createElement('div'); + Object.defineProperty(el, 'offsetWidth', { get: () => width }); + Object.defineProperty(el, 'offsetHeight', { get: () => height }); + jest.spyOn(el, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + right: width, + bottom: height, + width, + height, + x: 0, + y: 0, + toJSON: jest.fn(), + ...rect, + }); + return el; + }; + + test.each` + rotation | clientX | clientY | rectWidth | rectHeight | expectedX | expectedY + ${0} | ${75} | ${55} | ${100} | ${100} | ${75} | ${55} + ${-90} | ${0} | ${0} | ${100} | ${100} | ${100} | ${0} + ${-180} | ${25} | ${25} | ${100} | ${100} | ${75} | ${75} + ${-270} | ${75} | ${25} | ${100} | ${100} | ${25} | ${25} + `( + 'should map window coords ($clientX, $clientY) to element-local coords ($expectedX, $expectedY) at rotation=$rotation', + ({ rotation, clientX, clientY, rectWidth, rectHeight, expectedX, expectedY }) => { + const el = createElement(100, 100, { left: 0, top: 0, width: rectWidth, height: rectHeight }); + const [x, y] = getElementLocalPosition(clientX, clientY, el, rotation); + + expect(Math.round(x)).toBe(expectedX); + expect(Math.round(y)).toBe(expectedY); + }, + ); + + test('should account for element screen offset', () => { + const el = createElement(100, 100, { left: 100, top: 200, width: 100, height: 100 }); + // Click at center of element on screen → maps to center regardless of rotation + const [x, y] = getElementLocalPosition(150, 250, el, -90); + + expect(Math.round(x)).toBe(50); + expect(Math.round(y)).toBe(50); + }); + + test('should return raw coordinates when element is null', () => { + const [x, y] = getElementLocalPosition(123, 456, null, -90); + + expect(x).toBe(123); + expect(y).toBe(456); + }); + }); + describe('getRotatedShape()', () => { test.each` rotation | expectedShape diff --git a/src/utils/rotate.ts b/src/utils/rotate.ts index f36a0fa22..49c01f564 100644 --- a/src/utils/rotate.ts +++ b/src/utils/rotate.ts @@ -129,3 +129,46 @@ export function getRotatedPosition({ x, y }: Position, rotation: number): Positi const { x: rotatedX, y: rotatedY } = getRotatedShape({ height: 0, width: 0, x, y }, rotation); return { x: rotatedX, y: rotatedY }; } + +/** + * Converts a mouse position (in browser window coordinates) to a position relative to + * a CSS-rotated element's own top-left corner. + * + * When an element has a CSS rotation, its internal axes are rotated with it. + * This function maps window coordinates to element-local coordinates. + * + * If element is null, returns the raw coordinates as a passthrough. + * + * Assumes transform-origin: center (the CSS default), which matches how + * box-content-preview applies rotation to annotation layers. + */ +export function getElementLocalPosition( + clientX: number, + clientY: number, + element: HTMLElement | null, + rotation: number, +): [number, number] { + if (!element) { + return [clientX, clientY]; + } + + const rect = element.getBoundingClientRect(); + + if (!rotation) { + return [clientX - rect.left, clientY - rect.top]; + } + + // 1. Find the element's center (rotation doesn't move the center) + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + // 2. Get the mouse position relative to the center (in browser window coordinates) + const relX = clientX - centerX; + const relY = clientY - centerY; + + // 3. Apply inverse rotation to obtain the mouse position from center in element-local coordinates + const local = rotatePoint({ x: relX, y: relY }, -rotation); + + // 4. Convert coordinates from center-relative to top-left-relative + return [local.x + element.offsetWidth / 2, local.y + element.offsetHeight / 2]; +}