224
common/resources/client/ui/interactions/use-resize.ts
Executable file
224
common/resources/client/ui/interactions/use-resize.ts
Executable file
@@ -0,0 +1,224 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user