225 lines
6.7 KiB
TypeScript
Executable File
225 lines
6.7 KiB
TypeScript
Executable File
import {RefObject} from 'react';
|
|
import {
|
|
interactableEvent,
|
|
InteractableEvent,
|
|
InteractableRect,
|
|
} from './interactable-event';
|
|
import {usePointerEvents, UsePointerEventsProps} from './use-pointer-events';
|
|
import {activeInteraction, setActiveInteraction} from './active-interaction';
|
|
import {calcNewSizeFromAspectRatio} from './utils/calc-new-size-from-aspect-ratio';
|
|
import {restrictResizableWithinBoundary} from './utils/restrict-resizable-within-boundary';
|
|
import {domRectToObj} from './utils/dom-rect-to-obj';
|
|
|
|
export enum resizeHandlePosition {
|
|
topLeft = 'topLeft',
|
|
topRight = 'topRight',
|
|
bottomLeft = 'bottomLeft',
|
|
bottomRight = 'bottomRight',
|
|
}
|
|
|
|
type AspectRatio = number | null | undefined;
|
|
type InitialAspectRatio = AspectRatio | 'initial';
|
|
|
|
interface ResizeState {
|
|
currentRect?: InteractableRect;
|
|
resizeDir?: resizeHandlePosition;
|
|
initialAspectRatio?: AspectRatio;
|
|
boundaryRect?: InteractableRect;
|
|
}
|
|
|
|
let state: ResizeState = {};
|
|
|
|
const resetState = (value: ResizeState = {}) => {
|
|
setActiveInteraction(null);
|
|
state = value;
|
|
};
|
|
|
|
interface UseResizeProps {
|
|
boundaryRect?: InteractableRect;
|
|
boundaryRef?: RefObject<HTMLElement> | null;
|
|
restrictWithinBoundary?: boolean;
|
|
aspectRatio?: InitialAspectRatio;
|
|
onResizeStart?: (e: InteractableEvent) => void;
|
|
onResize?: (e: InteractableEvent) => void;
|
|
onResizeEnd?: (e: InteractableEvent) => void;
|
|
minWidth?: number;
|
|
minHeight?: number;
|
|
}
|
|
export function useResize({
|
|
aspectRatio,
|
|
boundaryRef,
|
|
boundaryRect,
|
|
restrictWithinBoundary = true,
|
|
minWidth = 50,
|
|
minHeight = 50,
|
|
...props
|
|
}: UseResizeProps) {
|
|
const pointerProps: UsePointerEventsProps = {
|
|
onMoveStart: (e, resizable) => {
|
|
const target = e.target as HTMLElement;
|
|
|
|
if (!target.dataset.resizeHandle || activeInteraction) {
|
|
return false;
|
|
}
|
|
|
|
resetState({
|
|
currentRect: domRectToObj(resizable.getBoundingClientRect()),
|
|
resizeDir: target.dataset.resizeHandle as resizeHandlePosition,
|
|
});
|
|
if (!state.currentRect) {
|
|
return false;
|
|
}
|
|
setActiveInteraction('resize');
|
|
|
|
if (boundaryRect) {
|
|
state.boundaryRect = boundaryRect;
|
|
} else if (boundaryRef?.current) {
|
|
state.boundaryRect = domRectToObj(
|
|
boundaryRef.current.getBoundingClientRect(),
|
|
);
|
|
}
|
|
|
|
// if we have a boundary, x, y will be relative to that boundary, otherwise it will be relative to window
|
|
if (state.currentRect && state.boundaryRect) {
|
|
state.currentRect.left -= state.boundaryRect.left;
|
|
state.currentRect.top -= state.boundaryRect.top;
|
|
}
|
|
state.initialAspectRatio =
|
|
state.currentRect.width / state.currentRect!.height;
|
|
props.onResizeStart?.(interactableEvent({rect: state.currentRect, e}));
|
|
},
|
|
onMove: (e, deltaX, deltaY) => {
|
|
if (!state.resizeDir || !state.currentRect) return;
|
|
|
|
const ratio =
|
|
aspectRatio === 'initial' ? state.initialAspectRatio : aspectRatio;
|
|
const newRect = resizeRect(state.currentRect, deltaX, deltaY, ratio);
|
|
const boundedRect = applyBounds(newRect, minWidth, minHeight, ratio);
|
|
|
|
props.onResize?.(
|
|
interactableEvent({rect: boundedRect, e, deltaX, deltaY}),
|
|
);
|
|
|
|
state.currentRect = newRect;
|
|
},
|
|
onMoveEnd: e => {
|
|
if (state.currentRect) {
|
|
props.onResizeEnd?.(interactableEvent({rect: state.currentRect, e}));
|
|
}
|
|
resetState();
|
|
},
|
|
};
|
|
|
|
const {domProps} = usePointerEvents(pointerProps);
|
|
|
|
return {resizeProps: domProps};
|
|
}
|
|
|
|
function resizeRect(
|
|
rect: InteractableRect,
|
|
deltaX: number,
|
|
deltaY: number,
|
|
ratio: AspectRatio,
|
|
): InteractableRect {
|
|
const prevRect = {...rect};
|
|
const newRect = {...rect};
|
|
|
|
if (state.resizeDir === resizeHandlePosition.topRight) {
|
|
newRect.width = Math.floor(newRect.width + deltaX);
|
|
if (ratio) {
|
|
newRect.height = Math.floor(newRect.width / ratio);
|
|
} else {
|
|
newRect.height = Math.floor(newRect.height - deltaY);
|
|
}
|
|
newRect.top = Math.floor(newRect.top + (prevRect.height - newRect.height));
|
|
} else if (state.resizeDir === resizeHandlePosition.bottomRight) {
|
|
newRect.width = Math.floor(newRect.width + deltaX);
|
|
if (ratio) {
|
|
newRect.height = Math.floor(newRect.width / ratio);
|
|
} else {
|
|
newRect.height = Math.floor(newRect.height + deltaY);
|
|
}
|
|
} else if (state.resizeDir === resizeHandlePosition.topLeft) {
|
|
newRect.width = Math.floor(newRect.width - deltaX);
|
|
if (ratio) {
|
|
newRect.height = Math.floor(newRect.width / ratio);
|
|
} else {
|
|
newRect.height = Math.floor(newRect.height - deltaY);
|
|
}
|
|
newRect.left = Math.floor(newRect.left + (prevRect.width - newRect.width));
|
|
newRect.top = Math.floor(newRect.top + (prevRect.height - newRect.height));
|
|
} else if (state.resizeDir === resizeHandlePosition.bottomLeft) {
|
|
newRect.width = Math.floor(newRect.width - deltaX);
|
|
if (ratio) {
|
|
newRect.height = Math.floor(newRect.width / ratio);
|
|
} else {
|
|
newRect.height = Math.floor(newRect.height + deltaY);
|
|
}
|
|
newRect.left = Math.floor(newRect.left + deltaX);
|
|
}
|
|
|
|
return newRect;
|
|
}
|
|
|
|
function applyBounds(
|
|
rect: InteractableRect,
|
|
minWidth: number,
|
|
minHeight: number,
|
|
ratio: AspectRatio,
|
|
): InteractableRect {
|
|
const isLeftSideHandle =
|
|
state.resizeDir === resizeHandlePosition.bottomLeft ||
|
|
state.resizeDir === resizeHandlePosition.topLeft;
|
|
const isTopSideHandle =
|
|
state.resizeDir === resizeHandlePosition.topRight ||
|
|
state.resizeDir === resizeHandlePosition.topLeft;
|
|
|
|
// bound width and height
|
|
let boundedRect = {...rect};
|
|
boundedRect.width = Math.max(minWidth, rect.width);
|
|
boundedRect.height = Math.max(minHeight, rect.height);
|
|
|
|
// compensate left when width is bounded
|
|
const widthRestriction = boundedRect.width - rect.width;
|
|
if (isLeftSideHandle && widthRestriction > 0) {
|
|
boundedRect.left -= widthRestriction;
|
|
}
|
|
|
|
// compensate top when height is bounded
|
|
const heightRestriction = boundedRect.height - rect.height;
|
|
if (isTopSideHandle && heightRestriction > 0) {
|
|
boundedRect.top -= heightRestriction;
|
|
}
|
|
|
|
if (state.boundaryRect) {
|
|
boundedRect = restrictResizableWithinBoundary(
|
|
boundedRect,
|
|
state.boundaryRect,
|
|
);
|
|
}
|
|
|
|
if (ratio) {
|
|
// adjust width/height based on specified aspect ratio
|
|
const oldWidth = boundedRect.width;
|
|
const oldHeight = boundedRect.height;
|
|
const size = calcNewSizeFromAspectRatio(
|
|
ratio,
|
|
boundedRect.width,
|
|
boundedRect.height,
|
|
);
|
|
boundedRect.width = size.width;
|
|
boundedRect.height = size.height;
|
|
|
|
// compensate top/left that was bound previously
|
|
if (isTopSideHandle) {
|
|
boundedRect.top += oldHeight - boundedRect.height;
|
|
}
|
|
|
|
if (isLeftSideHandle) {
|
|
boundedRect.left += oldWidth - boundedRect.width;
|
|
}
|
|
}
|
|
|
|
return boundedRect;
|
|
}
|