Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/components/RoiList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export interface RoiListProps<TData = unknown> {
getReadOnly?: GetReadOnlyCallback<TData>;
renderLabel?: RenderLabelCallback<TData>;
getOverlayOpacity?: GetOverlayOpacity<TData>;
allowRotate?: boolean;
displayRotationHandle?: boolean;
showGrid?: boolean;
/**
* Spacing (in device pixels) between vertical grid lines along the horizontal axis.
Expand Down Expand Up @@ -83,7 +83,7 @@ export function RoiList<TData = unknown>(props: RoiListProps<TData>) {
getReadOnly = () => false,
getOverlayOpacity = () => 0,
renderLabel = defaultRenderLabel,
allowRotate = false,
displayRotationHandle = false,
showGrid = false,
gridHorizontalLineCount = 2,
gridVerticalLineCount = 2,
Expand Down Expand Up @@ -122,7 +122,7 @@ export function RoiList<TData = unknown>(props: RoiListProps<TData>) {
renderLabel={renderLabel as RenderLabelCallback}
getReadOnly={getReadOnly as GetReadOnlyCallback}
getOverlayOpacity={getOverlayOpacity as GetOverlayOpacity}
allowRotate={allowRotate}
displayRotationHandle={displayRotationHandle}
isSelected={roi.id === selectedRoi}
showGrid={showGrid}
gridOptions={gridOptions}
Expand Down
18 changes: 14 additions & 4 deletions src/components/box/BoxSvg.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export interface BoxAnnotationProps {
className?: string;
isReadOnly: boolean;
getStyle: GetStyleCallback;
allowRotate: boolean;
displayRotationHandle: boolean;
showGrid: boolean;
gridOptions: GetGridLinesOptions;
}
Expand All @@ -38,7 +38,7 @@ export function BoxSvg({
isReadOnly,
getStyle,
box,
allowRotate,
displayRotationHandle,
showGrid,
gridOptions,
}: BoxAnnotationProps) {
Expand Down Expand Up @@ -161,7 +161,7 @@ export function BoxSvg({
))}

{isSelected &&
allowRotate &&
displayRotationHandle &&
(roi.action.type === 'rotating' || roi.action.type === 'idle') && (
<RoiBoxRotateHandler box={box} styles={styles} />
)}
Expand All @@ -181,6 +181,8 @@ function getCursor(
return 'crosshair';
} else if (action === 'moving') {
return 'move';
} else if (action === 'rotating') {
return 'ew-resize';
} else if (action === 'panning') {
return 'grab';
}
Expand All @@ -200,5 +202,13 @@ function getCursor(
}
}

return mode === 'draw' ? 'crosshair' : 'move';
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
switch (mode) {
case 'draw':
return 'crosshair';
case 'select_rotate':
return 'ew-resize';
default:
return 'move';
}
}
6 changes: 3 additions & 3 deletions src/components/box/RoiBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ interface RoiBoxProps {
getStyle: GetStyleCallback;
getReadOnly: GetReadOnlyCallback;
renderLabel: RenderLabelCallback;
allowRotate: boolean;
displayRotationHandle: boolean;
getOverlayOpacity: GetOverlayOpacity;
showGrid: boolean;
gridOptions: GetGridLinesOptions;
Expand All @@ -37,7 +37,7 @@ function RoiBoxInternal(props: RoiBoxProps): JSX.Element {
isSelected,
renderLabel,
getOverlayOpacity,
allowRotate,
displayRotationHandle,
showGrid,
gridOptions,
} = props;
Expand Down Expand Up @@ -88,7 +88,7 @@ function RoiBoxInternal(props: RoiBoxProps): JSX.Element {
box={box}
isReadOnly={isReadOnly}
getStyle={getStyle}
allowRotate={allowRotate}
displayRotationHandle={displayRotationHandle}
showGrid={showGrid}
gridOptions={gridOptions}
/>
Expand Down
10 changes: 7 additions & 3 deletions src/components/container/ContainerComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,12 @@

const getNewRoiData = useRef(props.getNewRoiData);

// TODO: useEffectEvent once we migrate to React 19

Check warning on line 71 in src/components/container/ContainerComponent.tsx

View workflow job for this annotation

GitHub Actions / nodejs / lint-eslint

Unexpected 'todo' comment: 'TODO: useEffectEvent once we migrate to...'
useEffect(() => {
getNewRoiData.current = props.getNewRoiData;
}, [props.getNewRoiData]);

// TODO: https://github.com/zakodium-oss/react-roi/issues/164

Check warning on line 76 in src/components/container/ContainerComponent.tsx

View workflow job for this annotation

GitHub Actions / nodejs / lint-eslint

Unexpected 'todo' comment: 'TODO:...'
// @ts-expect-error This can be ignored for now
useResizeObserver(containerRef, (entry) => {
const { width, height } = entry.contentRect;
Expand Down Expand Up @@ -273,6 +273,7 @@
action: ReactRoiAction,
lockPan: boolean,
): CSSProperties['cursor'] {
const isSelect = mode.startsWith('select');
if (action !== 'idle') {
if (action === 'drawing') {
return 'crosshair';
Expand All @@ -287,15 +288,15 @@
}

// No action, return cursor based on mode, lockPan and altKey
if (mode === 'select' && lockPan) {
if (isSelect && lockPan) {
return 'default';
}

if (altKey && !lockPan) {
return 'grab';
}

return mode !== 'select' ? 'crosshair' : 'grab';
return !isSelect ? 'crosshair' : 'grab';
}

function callPointerMoveActionHooks(
Expand Down Expand Up @@ -333,7 +334,10 @@
callbacks.onChange({
roi: newRoi ?? null,
actions,
actionType: selectedRoi.action.type,
actionType:
selectedRoi.action.type === 'rotating_free'
? 'rotating'
: selectedRoi.action.type,
roisBeforeCommit: state.committedRois,
});
}
Expand Down
9 changes: 9 additions & 0 deletions src/context/RoiProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ export interface RoiProviderInitialConfig<TData> {
commitRoiBoxStrategy?: CommitBoxStrategy;
rois?: Array<CommittedRoiProperties<TData>>;
selectedRoiId?: string;
/**
* When in select_rotate mode, this defines how much the mouse movement transnlates to a rotation angle.
* It is expressed in number of pixels for a full 360 degree rotation.
* @default 1200
*/
rotationResolution?: number;
}

interface RoiProviderProps<TData> {
Expand Down Expand Up @@ -115,6 +121,7 @@ function createInitialState<T>(
translation: [0, 0],
},
zoomDomain: initialConfig.zoom,
rotationResolution: initialConfig.rotationResolution,
};
}

Expand All @@ -133,6 +140,7 @@ export function RoiProvider<TData>(props: RoiProviderProps<TData>) {
selectedRoiId,
resizeStrategy = 'contain',
commitRoiBoxStrategy = 'round',
rotationResolution = 1200,
} = initialConfig;

const [state, dispatch] = useReducer(roiReducer, null, () =>
Expand All @@ -151,6 +159,7 @@ export function RoiProvider<TData>(props: RoiProviderProps<TData>) {
resizeStrategy,
commitRoiBoxStrategy,
selectedRoiId,
rotationResolution,
}),
);
const stateRef = useRef(state);
Expand Down
18 changes: 16 additions & 2 deletions src/context/roiReducer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ export interface ReactRoiState<TData = unknown> {
*/
commitRoiBoxStrategy: CommitBoxStrategy;

/**
* When in select_rotate mode, this defines how much the mouse movement transnlates to a rotation angle.
* It is expressed in number of pixels for a full 360 degree rotation.
* @default 1200
*/
rotationResolution: number;

/**
* Identification of the selected object
*/
Expand Down Expand Up @@ -179,6 +186,11 @@ export interface SelectBoxAndStartRotatePayload {
id: string;
}

export interface SelectBoxAndStartRotateFreePayload {
id: string;
rotationResolution: number;
}

export type RoiReducerAction =
| { type: 'SET_MODE'; payload: RoiMode }
| {
Expand Down Expand Up @@ -372,12 +384,14 @@ export function roiReducer(
break;
}
case 'SELECT_BOX_AND_START_MOVE': {
selectBoxAndStartAction(draft, action.payload.id, 'moving');
selectBoxAndStartAction(draft, action.payload.id, {
type: draft.mode === 'select_rotate' ? 'rotating_free' : 'moving',
});
break;
}

case 'SELECT_BOX_AND_START_ROTATE': {
selectBoxAndStartAction(draft, action.payload.id, 'rotating');
selectBoxAndStartAction(draft, action.payload.id, { type: 'rotating' });
break;
}

Expand Down
12 changes: 12 additions & 0 deletions src/context/updaters/pointerMove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ export function updateRoiBox(
roi.box.angle = computeAngleFromMousePosition({ x, y }, roi.box);
break;
}
case 'rotating_free': {
const angleDelta = (movement.x / draft.rotationResolution) * 2 * Math.PI;
roi.box.angle += angleDelta;
if (roi.box.angle > Math.PI) {
roi.box.angle -=
2 * Math.PI * Math.floor(roi.box.angle / (2 * Math.PI) + 1);
} else if (roi.box.angle < -Math.PI) {
roi.box.angle +=
2 * Math.PI * Math.floor(-roi.box.angle / (2 * Math.PI) + 1);
}
break;
}
case 'resizing': {
resize(draft, roi, movement);
break;
Expand Down
2 changes: 2 additions & 0 deletions src/context/updaters/roi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ const boundingStrategyMap: Record<RoiAction['type'], BoundingStrategy> = {
moving: 'move',
// If rotating, use move since it does not change the overall size of the ROI
rotating: 'move',
// eslint-disable-next-line camelcase
rotating_free: 'move',
external: 'none',
};

Expand Down
13 changes: 8 additions & 5 deletions src/context/updaters/selectBoxAndStartAction.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import type { Draft } from 'immer';

import type {
MoveAction,
RotateAction,
RotateFreeAction,
} from '../../types/Roi.ts';
import { assert } from '../../utilities/assert.js';
import { changeBoxRotationCenter } from '../../utilities/box.js';
import type { ReactRoiState } from '../roiReducer.js';

export function selectBoxAndStartAction(
draft: Draft<ReactRoiState>,
id: string,
action: 'rotating' | 'moving',
action: RotateFreeAction | RotateAction | MoveAction,
) {
if (draft.mode === 'draw') {
draft.selectedRoi = undefined;
Expand All @@ -17,11 +22,9 @@ export function selectBoxAndStartAction(
draft.selectedRoi = id;
const roi = rois.find((roi) => roi.id === id);
assert(roi, 'ROI not found');
draft.action = action;
draft.action = action.type === 'rotating_free' ? 'rotating' : action.type;

roi.action = {
type: action,
};
roi.action = action;
roi.box = changeBoxRotationCenter(roi.box, {
xRotationCenter: 'center',
yRotationCenter: 'center',
Expand Down
2 changes: 1 addition & 1 deletion src/context/updaters/startDraw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function startDraw(draft: ReactRoiState, payload: StartDrawPayload) {
draft.action = 'drawing';
break;
}
case 'select_rotate':
case 'select': {
if (!noUnselection) {
draft.selectedRoi = undefined;
Expand All @@ -68,7 +69,6 @@ export function startDraw(draft: ReactRoiState, payload: StartDrawPayload) {
if (!lockPan) {
draft.action = 'panning';
}

break;
}
default:
Expand Down
13 changes: 12 additions & 1 deletion src/types/Roi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,21 @@ export interface MoveAction {

export interface RotateAction {
/**
* Action of rotating an existing ROI
* Action of rotating an existing ROI, by using the ROI's rotation handle.
* The rotation is computed such that the line between the rotation center
* and the pointer is a line parallel to the rectangle's sides.
*/
type: 'rotating';
}

export interface RotateFreeAction {
/**
* Action of rotating the ROI by computing the angle only from the pointer's
* movement on the X axis.
*/
type: 'rotating_free';
}

export interface IdleAction {
/**
* No action is being performed
Expand All @@ -61,6 +71,7 @@ export type RoiAction =
| DrawAction
| MoveAction
| RotateAction
| RotateFreeAction
| ResizeAction
| ExternalAction;

Expand Down
2 changes: 1 addition & 1 deletion src/types/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ export interface PanZoom {
translation: [number, number];
}

export type RoiMode = 'select' | 'draw' | 'hybrid';
export type RoiMode = 'select' | 'draw' | 'hybrid' | 'select_rotate';

export type ResizeStrategy = 'cover' | 'contain' | 'center' | 'none';
1 change: 1 addition & 0 deletions src/utilities/box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ function commitRound(roi: CommittedBox, action: RoiAction): CommittedBox {
return commitExact(roi);
}
}
case 'rotating_free':
case 'rotating': {
return commitExact(roi);
}
Expand Down
2 changes: 1 addition & 1 deletion stories/edge-cases/init-box.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function RoundInitialValues() {
>
<Layout>
<RoiContainer target={<TargetImage src="/barbara.jpg" />}>
<RoiList allowRotate />
<RoiList displayRotationHandle />
</RoiContainer>
</Layout>
<CommittedRoisButton showImage={false} isDefaultShown />
Expand Down
2 changes: 1 addition & 1 deletion stories/full-examples/crop.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function CropImage() {
target={<TargetImage id="story-image" src="/barbara.jpg" />}
>
<RoiList
allowRotate
displayRotationHandle
showGrid
getOverlayOpacity={() => 0.6}
getStyle={(roi) => ({
Expand Down
1 change: 1 addition & 0 deletions stories/hooks/useActions/mode.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function ChangeMode() {
<option value="hybrid">Hybrid</option>
<option value="draw">Draw</option>
<option value="select">Select</option>
<option value="select_rotate">Select (rotate)</option>
</select>
);
}
Expand Down
Loading
Loading