UNPKG

react-moveable

Version:

A React Component that create Moveable, Draggable, Resizable, Scalable, Rotatable, Warpable, Pinchable, Groupable.

734 lines (653 loc) 23.8 kB
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, }, }; }); }