react-moveable
Version:
A React Component that create Moveable, Draggable, Resizable, Scalable, Rotatable, Warpable, Pinchable, Groupable.
734 lines (653 loc) • 23.8 kB
text/typescript
import { convertUnitSize, dot, flat, isNumber, isObject, throttle } from "@daybrush/utils";
import { diff } from "@egjs/children-differ";
import {
MoveableManagerInterface, SnappableProps,
SnappableState, SnapGuideline, SnapDirectionPoses,
PosGuideline, ElementGuidelineValue,
SnapElementRect,
NumericPosGuideline,
} from "../../types";
import { getRect, getAbsolutePosesByState, getRefTarget, calculateInversePosition, prefix, abs } from "../../utils";
import {
splitSnapDirectionPoses, getSnapDirections,
HORIZONTAL_NAMES_MAP, VERTICAL_NAMES_MAP, calculateContainerPos, SNAP_SKIP_NAMES_MAP,
} from "./utils";
export function getTotalGuidelines(
moveable: MoveableManagerInterface<SnappableProps, SnappableState>,
) {
const state = moveable.state;
const {
containerClientRect,
hasFixed,
} = state;
const {
overflow,
scrollHeight: containerHeight,
scrollWidth: containerWidth,
clientHeight: containerClientHeight,
clientWidth: containerClientWidth,
clientLeft,
clientTop,
} = containerClientRect;
const {
snapGap = true,
verticalGuidelines,
horizontalGuidelines,
snapThreshold = 5,
maxSnapElementGuidelineDistance = Infinity,
isDisplayGridGuidelines,
} = moveable.props;
const { top, left, bottom, right } = getRect(getAbsolutePosesByState(moveable.state));
const targetRect = { top, left, bottom, right, center: (left + right) / 2, middle: (top + bottom) / 2 };
const elementGuidelines = getElementGuidelines(moveable);
let totalGuidelines: SnapGuideline[] = [...elementGuidelines];
const snapThresholdMultiples = (state.snapThresholdInfo?.multiples ?? [1, 1]).map(n => n * snapThreshold);
if (snapGap) {
totalGuidelines.push(...getGapGuidelines(
moveable,
targetRect,
snapThresholdMultiples,
));
}
const snapOffset = {
...(state.snapOffset || {
left: 0,
top: 0,
bottom: 0,
right: 0,
}),
};
totalGuidelines.push(...getGridGuidelines(
moveable,
overflow ? containerWidth! : containerClientWidth!,
overflow ? containerHeight! : containerClientHeight!,
clientLeft,
clientTop,
snapOffset,
isDisplayGridGuidelines,
));
if (hasFixed) {
const { left, top } = containerClientRect;
snapOffset.left += left;
snapOffset.top += top;
snapOffset.right += left;
snapOffset.bottom += top;
}
totalGuidelines.push(...getDefaultGuidelines(
horizontalGuidelines || false,
verticalGuidelines || false,
overflow ? containerWidth! : containerClientWidth!,
overflow ? containerHeight! : containerClientHeight!,
clientLeft,
clientTop,
snapOffset,
));
totalGuidelines = totalGuidelines.filter(({ element, elementRect, type }) => {
if (!element || !elementRect) {
return true;
}
const rect = elementRect.rect;
return checkBetweenRects(targetRect, rect, type, maxSnapElementGuidelineDistance);
});
return totalGuidelines;
}
export function getGapGuidelines(
moveable: MoveableManagerInterface<SnappableProps, SnappableState>,
targetRect: SnapDirectionPoses,
snapThresholds: number[],
) {
const {
maxSnapElementGuidelineDistance = Infinity,
maxSnapElementGapDistance = Infinity,
} = moveable.props;
const elementRects = moveable.state.elementRects;
const gapGuidelines: SnapGuideline[] = [];
[
["vertical", VERTICAL_NAMES_MAP, HORIZONTAL_NAMES_MAP] as const,
["horizontal", HORIZONTAL_NAMES_MAP, VERTICAL_NAMES_MAP] as const,
].forEach(([type, mainNames, sideNames]) => {
const targetStart = targetRect[mainNames.start]!;
const targetEnd = targetRect[mainNames.end]!;
const targetCenter = targetRect[mainNames.center]!;
const targetStart2 = targetRect[sideNames.start]!;
const targetEnd2 = targetRect[sideNames.end]!;
// element : moveable
const snapThresholdMap = {
left: snapThresholds[0],
top: snapThresholds[1],
};
function getDist(elementRect: SnapElementRect) {
const rect = elementRect.rect;
const snapThreshold = snapThresholdMap[mainNames.start];
if (rect[mainNames.end]! < targetStart + snapThreshold) {
return targetStart - rect[mainNames.end]!;
} else if (targetEnd - snapThreshold < rect[mainNames.start]!) {
return rect[mainNames.start]! - targetEnd;
} else {
return -1;
}
}
const nextElementRects = elementRects.filter(elementRect => {
const rect = elementRect.rect;
if (rect[sideNames.start]! > targetEnd2 || rect[sideNames.end]! < targetStart2) {
return false;
}
return getDist(elementRect) > 0;
}).sort((a, b) => {
return getDist(a) - getDist(b);
});
const groups: SnapElementRect[][] = [];
nextElementRects.forEach(snapRect1 => {
nextElementRects.forEach(snapRect2 => {
if (snapRect1 === snapRect2) {
return;
}
const { rect: rect1 } = snapRect1;
const { rect: rect2 } = snapRect2;
const rect1Start = rect1[sideNames.start]!;
const rect1End = rect1[sideNames.end]!;
const rect2Start = rect2[sideNames.start]!;
const rect2End = rect2[sideNames.end]!;
if (rect1Start > rect2End || rect2Start > rect1End) {
return;
}
groups.push([snapRect1, snapRect2]);
});
});
groups.forEach(([snapRect1, snapRect2]) => {
const { rect: rect1 } = snapRect1;
const { rect: rect2 } = snapRect2;
const rect1Start = rect1[mainNames.start]!;
const rect1End = rect1[mainNames.end]!;
const rect2Start = rect2[mainNames.start]!;
const rect2End = rect2[mainNames.end]!;
const snapThreshold = snapThresholdMap[mainNames.start];
let gap = 0;
let pos = 0;
let isStart = false;
let isCenter = false;
let isEnd = false;
if (rect1End <= targetStart && targetEnd <= rect2Start) {
// (l)element1(r) : (l)target(r) : (l)element2(r)
isCenter = true;
gap = ((rect2Start - rect1End) - (targetEnd - targetStart)) / 2;
pos = rect1End + gap + (targetEnd - targetStart) / 2;
if (abs(pos - targetCenter) > snapThreshold) {
return;
}
} else if (rect1End < rect2Start && rect2End < targetStart + snapThreshold) {
// (l)element1(r) : (l)element2(r) : (l)target
isStart = true;
gap = rect2Start - rect1End;
pos = rect2End + gap;
if (abs(pos - targetStart) > snapThreshold) {
return;
}
} else if (rect1End < rect2Start && targetEnd - snapThreshold < rect1Start) {
// target(r) : (l)element1(r) : (l)element2(r)
isEnd = true;
gap = rect2Start - rect1End;
pos = rect1Start - gap;
if (abs(pos - targetEnd) > snapThreshold) {
return;
}
} else {
return;
}
if (!gap) {
return;
}
if (!checkBetweenRects(targetRect, rect2, type, maxSnapElementGuidelineDistance)) {
return;
}
if (gap > maxSnapElementGapDistance) {
return;
}
gapGuidelines.push({
type,
pos: type === "vertical" ? [pos, 0] : [0, pos],
element: snapRect2.element,
size: 0,
className: snapRect2.className,
isStart,
isCenter,
isEnd,
gap,
hide: true,
gapRects: [snapRect1, snapRect2],
direction: "",
elementDirection: "",
});
});
});
return gapGuidelines;
}
export function startGridGroupGuidelines(
moveable: MoveableManagerInterface<SnappableProps, SnappableState>,
clientLeft: number,
clientTop: number,
snapOffset: { left: number, top: number, right: number, bottom: number },
) {
const props = moveable.props;
const state = moveable.state;
const {
snapGridAll,
} = props;
const {
snapGridWidth = 0,
snapGridHeight = 0,
} = props;
const {
snapRenderInfo,
} = state;
const hasDirection = snapRenderInfo && (snapRenderInfo.direction?.[0] || snapRenderInfo.direction?.[1]);
const moveables = moveable.moveables;
const ignores = [false, false];
// snap group's all child to grid.
if (
snapGridAll
&& moveables
&& hasDirection
&& (snapGridWidth || snapGridHeight)
) {
if (state.snapThresholdInfo) {
return;
}
state.snapThresholdInfo = {
multiples: [1, 1],
offset: [0, 0],
};
const rect = moveable.getRect();
const children = rect.children;
const direction = snapRenderInfo.direction!;
if (children) {
const result = direction.map((dir, i) => {
const {
snapSize,
posName,
sizeName,
clientOffset,
} = i === 0 ? {
snapSize: snapGridWidth,
posName: "left",
sizeName: "width",
clientOffset: snapOffset.left - clientLeft,
} as const : {
snapSize: snapGridHeight,
posName: "top",
sizeName: "height",
clientOffset: snapOffset.top - clientTop,
} as const;
if (!snapSize) {
return {
dir,
multiple: 1,
snapSize,
snapOffset: 0,
};
}
const rectSize = rect[sizeName];
const rectPos = rect[posName];
// 사이즈보다 만약 작다면 어떻게 해야되죠?
const childSizes = flat(children.map(child => {
return [
(child[posName] - rectPos),
(child[sizeName]),
(rectSize - child[sizeName] - child[posName] + rectPos),
];
})).filter(v => v).sort((a, b) => {
return a - b;
});
const firstChildSize = childSizes[0];
const childSnapSizes = childSizes.map(size => throttle(size / firstChildSize, 0.1) * snapSize);
let n = 1;
const rectRatio = throttle(rectSize / firstChildSize, 0.1);
for (n = 1; n <= 10; ++n) {
if (childSnapSizes.every(childSize => {
return childSize * n % 1 === 0;
})) {
break;
}
}
// dir 1 (fixed -1)
// dir 0 (fixed 0)
// dir -1 (fixed 1)
const ratio = (-dir + 1) / 2;
const offsetPos = dot(
rectPos - clientOffset,
rectPos - clientOffset + rectSize,
ratio, 1 - ratio,
);
return {
multiple: rectRatio * n,
dir,
snapSize,
snapOffset: Math.round(offsetPos / snapSize),
};
});
const multiples = result.map(r => r.multiple || 1);
state.snapThresholdInfo.multiples = multiples;
state.snapThresholdInfo.offset = result.map(r => r.snapOffset);
result.forEach((r, i) => {
if (r.snapSize) {
ignores[i] = true;
}
});
}
} else {
state.snapThresholdInfo = null;
}
}
export function getGridGuidelines(
moveable: MoveableManagerInterface<SnappableProps, SnappableState>,
containerWidth: number,
containerHeight: number,
clientLeft = 0,
clientTop = 0,
snapOffset: { left: number, top: number, right: number, bottom: number },
isDisplayGridGuidelines?: boolean,
): SnapGuideline[] {
const props = moveable.props;
const state = moveable.state;
let {
snapGridWidth = 0,
snapGridHeight = 0,
} = props;
const guidelines: SnapGuideline[] = [];
const {
left: snapOffsetLeft,
top: snapOffsetTop,
} = snapOffset;
let startOffset = [0, 0];
startGridGroupGuidelines(
moveable,
clientLeft,
clientTop,
snapOffset,
);
const snapThresholdInfo = state.snapThresholdInfo;
const defaultSnapGridWidth = snapGridWidth;
const defaultSnapGridHeight = snapGridHeight;
if (snapThresholdInfo) {
snapGridWidth *= snapThresholdInfo.multiples[0] || 1;
snapGridHeight *= snapThresholdInfo.multiples[1] || 1;
startOffset = snapThresholdInfo.offset;
}
if (snapGridHeight) {
const pushGuideline = (pos: number) => {
guidelines.push({
type: "horizontal",
pos: [
snapOffsetLeft,
throttle(startOffset[1] * defaultSnapGridHeight + pos - clientTop + snapOffsetTop, 0.1),
],
className: prefix("grid-guideline"),
size: containerWidth!,
hide: !isDisplayGridGuidelines,
direction: "",
grid: true,
});
};
for (let pos = 0; pos <= containerHeight * 2; pos += snapGridHeight) {
pushGuideline(pos);
}
for (let pos = -snapGridHeight; pos >= -containerHeight; pos -= snapGridHeight) {
pushGuideline(pos);
}
}
if (snapGridWidth) {
const pushGuideline = (pos: number) => {
guidelines.push({
type: "vertical",
pos: [
throttle(startOffset[0] * defaultSnapGridWidth + pos - clientLeft + snapOffsetLeft, 0.1),
snapOffsetTop,
],
className: prefix("grid-guideline"),
size: containerHeight!,
hide: !isDisplayGridGuidelines,
direction: "",
grid: true,
});
};
for (let pos = 0; pos <= containerWidth * 2; pos += snapGridWidth) {
pushGuideline(pos);
}
for (let pos = -snapGridWidth; pos >= -containerWidth; pos -= snapGridWidth) {
pushGuideline(pos);
}
}
return guidelines;
}
export function checkBetweenRects(
rect1: SnapDirectionPoses,
rect2: SnapDirectionPoses,
type: "horizontal" | "vertical",
distance: number,
) {
if (type === "horizontal") {
return abs(rect1.right! - rect2.left!) <= distance
|| abs(rect1.left! - rect2.right!) <= distance
|| rect1.left! <= rect2.right! && rect2.left! <= rect1.right!;
} else if (type === "vertical") {
return abs(rect1.bottom! - rect2.top!) <= distance
|| abs(rect1.top! - rect2.bottom!) <= distance
|| rect1.top! <= rect2.bottom! && rect2.top! <= rect1.bottom!;
}
return true;
}
export function getElementGuidelines(
moveable: MoveableManagerInterface<SnappableProps, SnappableState>,
) {
const state = moveable.state;
const {
elementGuidelines = [],
} = moveable.props;
if (!elementGuidelines.length) {
state.elementRects = [];
return [];
}
const prevValues = (state.elementRects || []).filter(snapRect => !snapRect.refresh);
const nextElementGuidelines = elementGuidelines.map(el => {
if (isObject(el) && "element" in el) {
return {
...el,
element: getRefTarget(el.element, true)!,
};
}
return {
element: getRefTarget(el, true)!,
};
}).filter(value => {
return value.element;
}) as ElementGuidelineValue[];
const {
maintained,
added,
} = diff(prevValues.map(v => v.element), nextElementGuidelines.map(v => v.element));
const nextValues: SnapElementRect[] = [];
maintained.forEach(([prevIndex, nextIndex]) => {
nextValues[nextIndex] = prevValues[prevIndex];
});
getSnapElementRects(moveable, added.map(index => nextElementGuidelines[index])).map((rect, i) => {
nextValues[added[i]] = rect;
});
state.elementRects = nextValues;
const elementSnapDirections = getSnapDirections(moveable.props.elementSnapDirections);
const nextGuidelines: SnapGuideline[] = [];
nextValues.forEach(snapRect => {
const {
element,
top: topValue = elementSnapDirections.top,
left: leftValue = elementSnapDirections.left,
right: rightValue = elementSnapDirections.right,
bottom: bottomValue = elementSnapDirections.bottom,
center: centerValue = elementSnapDirections.center,
middle: middleValue = elementSnapDirections.middle,
className,
rect,
} = snapRect;
const {
horizontal,
vertical,
horizontalNames,
verticalNames,
} = splitSnapDirectionPoses({
top: topValue,
right: rightValue,
left: leftValue,
bottom: bottomValue,
center: centerValue,
middle: middleValue,
}, rect);
const rectTop = rect.top!;
const rectLeft = rect.left!;
const width = rect.right! - rectLeft;
const height = rect.bottom! - rectTop;
const sizes = [width, height];
vertical.forEach((pos, i) => {
nextGuidelines.push({
type: "vertical", element, pos: [
throttle(pos, 0.1),
rectTop,
], size: height,
sizes,
className,
elementRect: snapRect,
elementDirection: SNAP_SKIP_NAMES_MAP[verticalNames[i]] || verticalNames[i],
direction: "",
});
});
horizontal.forEach((pos, i) => {
nextGuidelines.push({
type: "horizontal",
element,
pos: [
rectLeft,
throttle(pos, 0.1),
],
size: width,
sizes,
className,
elementRect: snapRect,
elementDirection: SNAP_SKIP_NAMES_MAP[horizontalNames[i]] || horizontalNames[i],
direction: "",
});
});
});
return nextGuidelines;
}
function getObjectGuidelines(
guidelines: Array<PosGuideline | number | string> | false,
containerSize: number,
): NumericPosGuideline[] {
return guidelines ? guidelines.map(info => {
const posGuideline = isObject(info) ? info : { pos: info };
const pos = posGuideline.pos;
if (isNumber(pos)) {
return posGuideline as NumericPosGuideline;
} else {
return {
...posGuideline,
pos: convertUnitSize(pos, containerSize),
};
}
}) : [];
}
export function getDefaultGuidelines(
horizontalGuidelines: Array<PosGuideline | number | string> | false,
verticalGuidelines: Array<PosGuideline | number | string> | false,
width: number,
height: number,
clientLeft = 0,
clientTop = 0,
snapOffset = { left: 0, top: 0, right: 0, bottom: 0 },
): SnapGuideline[] {
const guidelines: SnapGuideline[] = [];
const {
left: snapOffsetLeft,
top: snapOffsetTop,
bottom: snapOffsetBottom,
right: snapOffsetRight,
} = snapOffset;
const snapWidth = width! + snapOffsetRight - snapOffsetLeft;
const snapHeight = height! + snapOffsetBottom - snapOffsetTop;
getObjectGuidelines(horizontalGuidelines, snapHeight).forEach(posInfo => {
guidelines.push({
type: "horizontal",
pos: [
snapOffsetLeft,
throttle(posInfo.pos - clientTop + snapOffsetTop, 0.1),
],
size: snapWidth,
className: posInfo.className,
direction: "",
});
});
getObjectGuidelines(verticalGuidelines, snapWidth).forEach(posInfo => {
guidelines.push({
type: "vertical",
pos: [
throttle(posInfo.pos - clientLeft + snapOffsetLeft, 0.1),
snapOffsetTop,
],
size: snapHeight,
className: posInfo.className,
direction: "",
});
});
return guidelines;
}
export function getSnapElementRects(
moveable: MoveableManagerInterface<SnappableProps, SnappableState>,
values: ElementGuidelineValue[],
): SnapElementRect[] {
if (!values.length) {
return [];
}
const groupable = moveable.props.groupable;
const state = moveable.state;
const {
containerClientRect,
// targetClientRect: {
// top: clientTop,
// left: clientLeft,
// },
rootMatrix,
is3d,
offsetDelta,
} = state;
const n = is3d ? 4 : 3;
const [containerLeft, containerTop] = calculateContainerPos(rootMatrix, containerClientRect, n);
// const poses = getAbsolutePosesByState(state);
// const {
// minX: targetLeft,
// minY: targetTop,
// } = getMinMaxs(poses);
// const [distLeft, distTop] = minus([targetLeft, targetTop], calculateInversePosition(rootMatrix, [
// clientLeft - containerLeft,
// clientTop - containerTop,
// ], n)).map(pos => roundSign(pos));
const offsetLeft = groupable ? 0 : offsetDelta[0];
const offsetTop = groupable ? 0 : offsetDelta[1];
return values.map(value => {
const rect = value.element.getBoundingClientRect();
const left = rect.left - containerLeft - offsetLeft;
const top = rect.top - containerTop - offsetTop;
const bottom = top + rect.height;
const right = left + rect.width;
const [elementLeft, elementTop] = calculateInversePosition(rootMatrix, [left, top], n);
const [elementRight, elementBottom] = calculateInversePosition(rootMatrix, [right, bottom], n);
return {
...value,
rect: {
left: elementLeft,
right: elementRight,
top: elementTop,
bottom: elementBottom,
center: (elementLeft + elementRight) / 2,
middle: (elementTop + elementBottom) / 2,
},
};
});
}