packages/react-moveable/src/ables/Roundable.tsx

import {
    prefix, triggerEvent,
    fillParams, fillEndParams, calculatePosition,
    fillCSSObject,
    catchEvent,
    getComputedStyle,
} from "../utils";
import {
    Renderer, RoundableProps, OnRoundStart,
    RoundableState, OnRound, ControlPose, OnRoundEnd,
    MoveableManagerInterface,
    OnRoundGroup,
    MoveableGroupInterface,
    OnRoundGroupStart,
    OnRoundGroupEnd,
} from "../types";
import { splitSpace } from "@daybrush/utils";
import { setDragStart, getDragDist, calculatePointerDist } from "../gesto/GestoUtils";
import { minus, plus } from "@scena/matrix";
import {
    getRadiusValues,
    getRadiusStyles,
    splitRadiusPoses,
} from "./roundable/borderRadius";
import { fillChildEvents } from "../groupUtils";


function addBorderRadiusByLine(
    controlPoses: ControlPose[],
    lineIndex: number,
    distX: number,
    distY: number,
) {
    // lineIndex
    // 0 top
    // 1 right
    // 2 bottom
    // 3 left

    const horizontalsLength = controlPoses.filter(({ virtual, horizontal }) => horizontal && !virtual).length;
    const verticalsLength = controlPoses.filter(({ virtual, vertical }) => vertical && !virtual).length;
    let controlIndex = -1;

    //top
    if (lineIndex === 0) {
        if (horizontalsLength === 0) {
            controlIndex = 0;
        } else if (horizontalsLength === 1) {
            controlIndex = 1;
        }
    }
    // bottom
    if (lineIndex === 2) {
        if (horizontalsLength <= 2) {
            controlIndex = 2;
        } else if (horizontalsLength <= 3) {
            controlIndex = 3;
        }
    }
    // left
    if (lineIndex === 3) {
        if (verticalsLength === 0) {
            controlIndex = 4;
        } else if (verticalsLength < 4) {
            controlIndex = 7;
        }
    }

    // right
    if (lineIndex === 1) {
        if (verticalsLength <= 1) {
            controlIndex = 5;
        } else if (verticalsLength <= 2) {
            controlIndex = 6;
        }
    }
    if (controlIndex === -1 || !controlPoses[controlIndex].virtual) {
        return;
    }
    const controlPoseInfo = controlPoses[controlIndex];

    addBorderRadius(controlPoses, controlIndex);

    if (controlIndex < 4) {
        controlPoseInfo.pos[0] = distX;
    } else {
        controlPoseInfo.pos[1] = distY;
    }
}
function addBorderRadius(
    controlPoses: ControlPose[],
    index: number,
) {
    if (index < 4) {
        controlPoses.slice(0, index + 1).forEach(info => {
            info.virtual = false;
        });
    } else {
        if (controlPoses[0].virtual) {
            controlPoses[0].virtual = false;
        }
        controlPoses.slice(4, index + 1).forEach(info => {
            info.virtual = false;
        });
    }
}
function removeBorderRadius(
    controlPoses: ControlPose[],
    index: number,
) {
    if (index < 4) {
        controlPoses.slice(index, 4).forEach(info => {
            info.virtual = true;
        });
    } else {
        controlPoses.slice(index).forEach(info => {
            info.virtual = true;
        });
    }
}
function getBorderRadius(
    borderRadius: string,
    width: number,
    height: number,
    minCounts: number[] = [0, 0],
    full?: boolean,
) {
    let values: string[] = [];

    if (!borderRadius || borderRadius === "0px") {
        values = [];
    } else {
        values = splitSpace(borderRadius);
    }

    return getRadiusValues(values, width, height, 0, 0, minCounts, full);
}

function triggerRoundEvent(
    moveable: MoveableManagerInterface<RoundableProps, RoundableState>,
    e: any,
    dist: number[],
    delta: number[],
    nextPoses: ControlPose[],
) {
    const state = moveable.state;
    const {
        width,
        height,
    } = state;
    const {
        raws,
        styles,
        radiusPoses,
    } = getRadiusStyles(
        nextPoses,
        moveable.props.roundRelative!,
        width,
        height,
    );
    const {
        horizontals,
        verticals,
    } = splitRadiusPoses(radiusPoses, raws);
    const borderRadius = styles.join(" ");

    state.borderRadiusState = borderRadius;
    const params = fillParams<OnRound>(moveable, e, {
        horizontals,
        verticals,
        borderRadius,
        width,
        height,
        delta,
        dist,
        ...fillCSSObject({
            borderRadius,
        }, e),
    });
    triggerEvent(moveable, "onRound", params);

    return params;
}


function getStyleBorderRadius(moveable: MoveableManagerInterface<RoundableProps, RoundableState>) {
    const {
        style,
    } = moveable.getState();
    let borderRadius = style.borderRadius || "";

    if (!borderRadius && moveable.props.groupable) {
        const firstMoveable = moveable.moveables![0];
        const firstTarget = moveable.getTargets()[0];


        if (firstTarget) {
            if (firstMoveable?.props.target === firstTarget) {
                borderRadius = moveable.moveables![0]?.state.style.borderRadius ?? "";
                style.borderRadius = borderRadius;
            } else {
                borderRadius = getComputedStyle(firstTarget).borderRadius;
                style.borderRadius = borderRadius;
            }
        }

    }
    return borderRadius;
}

/**
 * @namespace Moveable.Roundable
 * @description Whether to show and drag or double click border-radius
 */

export default {
    name: "roundable",
    props: [
        "roundable",
        "roundRelative",
        "minRoundControls",
        "maxRoundControls",
        "roundClickable",
        "roundPadding",
        "isDisplayShadowRoundControls",
    ] as const,
    events: [
        "roundStart",
        "round",
        "roundEnd",
        "roundGroupStart",
        "roundGroup",
        "roundGroupEnd",
    ] as const,
    css: [
        `.control.border-radius {
background: #d66;
cursor: pointer;
z-index: 3;
}`,
        `.control.border-radius.vertical {
background: #d6d;
z-index: 2;
}`,
        `.control.border-radius.virtual {
opacity: 0.5;
z-index: 1;
}`,
        `:host.round-line-clickable .line.direction {
cursor: pointer;
}`,
    ],
    className(moveable: MoveableManagerInterface<RoundableProps, RoundableState>) {
        const roundClickable = moveable.props.roundClickable;

        return roundClickable === true || roundClickable === "line" ? prefix("round-line-clickable") : "";
    },
    requestStyle(): Array<keyof CSSStyleDeclaration> {
        return ["borderRadius"];
    },
    requestChildStyle(): Array<keyof CSSStyleDeclaration> {
        return ["borderRadius"];
    },
    render(moveable: MoveableManagerInterface<RoundableProps, RoundableState>, React: Renderer): any {
        const {
            target,
            width,
            height,
            allMatrix,
            is3d,
            left,
            top,
            borderRadiusState,
        } = moveable.getState();

        const {
            minRoundControls = [0, 0],
            maxRoundControls = [4, 4],
            zoom,
            roundPadding = 0,
            isDisplayShadowRoundControls,
            groupable,
        } = moveable.props;

        if (!target) {
            return null;
        }

        const borderRadius = borderRadiusState || getStyleBorderRadius(moveable);
        const n = is3d ? 4 : 3;
        const radiusValues = getBorderRadius(
            borderRadius,
            width, height,
            minRoundControls,
            true,
        );

        if (!radiusValues) {
            return null;
        }
        let verticalCount = 0;
        let horizontalCount = 0;
        const basePos = groupable ? [0, 0] : [left, top];

        return radiusValues.map((v, i) => {
            const horizontal = v.horizontal;
            const vertical = v.vertical;
            const direction = v.direction || "";
            const originalPos = [...v.pos];

            horizontalCount += Math.abs(horizontal);
            verticalCount += Math.abs(vertical);


            if (horizontal && direction.indexOf("n") > -1) {
                originalPos[1] -= roundPadding;
            }
            if (vertical && direction.indexOf("w") > -1) {
                originalPos[0] -= roundPadding;
            }
            if (horizontal && direction.indexOf("s") > -1) {
                originalPos[1] += roundPadding;
            }
            if (vertical && direction.indexOf("e") > -1) {
                originalPos[0] += roundPadding;
            }
            const pos = minus(calculatePosition(allMatrix, originalPos, n), basePos);
            const isDisplayVerticalShadow
                = isDisplayShadowRoundControls
                && isDisplayShadowRoundControls !== "horizontal";
            const isDisplay = v.vertical
                ? verticalCount <= maxRoundControls[1] && (isDisplayVerticalShadow || !v.virtual)
                : horizontalCount <= maxRoundControls[0] && (isDisplayShadowRoundControls || !v.virtual);

            return <div key={`borderRadiusControl${i}`}
                className={prefix(
                    "control", "border-radius",
                    v.vertical ? "vertical" : "",
                    v.virtual ? "virtual" : "",
                )}
                data-radius-index={i}
                style={{
                    display: isDisplay ? "block" : "none",
                    transform: `translate(${pos[0]}px, ${pos[1]}px) scale(${zoom})`,
                }}></div>;
        });
    },
    dragControlCondition(moveable: any, e: any) {
        if (!e.inputEvent || e.isRequest) {
            return false;
        }
        const className = (e.inputEvent.target.getAttribute("class") || "");

        return className.indexOf("border-radius") > -1
            || (className.indexOf("moveable-line") > -1 && className.indexOf("moveable-direction") > -1);
    },
    dragGroupControlCondition(moveable: any, e: any) {
        return this.dragControlCondition(moveable, e);
    },
    dragControlStart(moveable: MoveableManagerInterface<RoundableProps, RoundableState>, e: any) {
        const { inputEvent, datas } = e;
        const inputTarget = inputEvent.target;
        const className = (inputTarget.getAttribute("class") || "");
        const isControl = className.indexOf("border-radius") > -1;
        const isLine = className.indexOf("moveable-line") > -1 && className.indexOf("moveable-direction") > -1;
        const controlIndex = isControl ? parseInt(inputTarget.getAttribute("data-radius-index"), 10) : -1;
        let lineIndex = -1;

        if (isLine) {
            const indexAttr = inputTarget.getAttribute("data-line-key")! || "";

            if (indexAttr) {
                lineIndex = parseInt(indexAttr.replace(/render-line-/g, ""), 10);

                if (isNaN(lineIndex)) {
                    lineIndex = -1;
                }
            }
        }

        if (!isControl && !isLine) {
            return false;
        }

        const params = fillParams<OnRoundStart>(moveable, e, {});

        const result = triggerEvent(
            moveable, "onRoundStart", params);

        if (result === false) {
            return false;
        }

        datas.lineIndex = lineIndex;
        datas.controlIndex = controlIndex;
        datas.isControl = isControl;
        datas.isLine = isLine;

        setDragStart(moveable, e);

        const {
            roundRelative,
            minRoundControls = [0, 0],
        } = moveable.props;
        const state = moveable.state;
        const {
            width,
            height,
        } = state;

        datas.isRound = true;
        datas.prevDist = [0, 0];
        const borderRadius = getStyleBorderRadius(moveable);
        const controlPoses = getBorderRadius(
            borderRadius || "",
            width,
            height,
            minRoundControls,
            true,
        ) || [];

        datas.controlPoses = controlPoses;

        state.borderRadiusState = getRadiusStyles(
            controlPoses,
            roundRelative!,
            width,
            height,
        ).styles.join(" ");
        return params;
    },
    dragControl(moveable: MoveableManagerInterface<RoundableProps, RoundableState>, e: any) {
        const { datas } = e;
        const controlPoses = datas.controlPoses as ControlPose[];

        if (!datas.isRound || !datas.isControl || !controlPoses.length) {
            return false;
        }
        const index = datas.controlIndex as number;

        const [distX, distY] = getDragDist(e);
        const dist = [distX, distY];
        const delta = minus(dist, datas.prevDist);
        const {
            maxRoundControls = [4, 4],
        } = moveable.props;
        const { width, height } = moveable.state;
        const selectedControlPose = controlPoses[index];

        const selectedVertical = selectedControlPose.vertical;
        const selectedHorizontal = selectedControlPose.horizontal;

        // 0: [0, 1, 2, 3] maxCount === 1
        // 0: [0, 2] maxCount === 2
        // 1: [1, 3] maxCount === 2

        // 0: [0] maxCount === 3
        // 1: [1, 3] maxCount === 3

        const dists = controlPoses.map(pose => {
            const { horizontal, vertical } = pose;
            const poseDist = [
                horizontal * selectedHorizontal * dist[0],
                vertical * selectedVertical * dist[1],
            ];
            if (horizontal) {
                if (maxRoundControls[0] === 1) {
                    return poseDist;
                } else if (maxRoundControls[0] < 4 && horizontal !== selectedHorizontal) {
                    return poseDist;
                }
            } else if (maxRoundControls[1] === 0) {
                poseDist[1] = vertical * selectedHorizontal * dist[0] / width * height;

                return poseDist;
            } else if (selectedVertical) {
                if (maxRoundControls[1] === 1) {
                    return poseDist;
                } else if (maxRoundControls[1] < 4 && vertical !== selectedVertical) {
                    return poseDist;
                }
            }
            return [0, 0];
        });

        dists[index] = dist;
        const nextPoses = controlPoses.map((info, i) => {
            return {
                ...info,
                pos: plus(info.pos, dists[i]),
            };
        });

        if (index < 4) {
            nextPoses.slice(0, index + 1).forEach(info => {
                info.virtual = false;
            });
        } else {
            nextPoses.slice(4, index + 1).forEach(info => {
                info.virtual = false;
            });
        }

        datas.prevDist = [distX, distY];

        return triggerRoundEvent(
            moveable,
            e,
            dist,
            delta,
            nextPoses,
        );
    },
    dragControlEnd(moveable: MoveableManagerInterface<RoundableProps, RoundableState>, e: any) {
        const state = moveable.state;

        state.borderRadiusState = "";
        const { datas, isDouble } = e;
        if (!datas.isRound) {
            return false;
        }
        const {
            isControl,
            controlIndex,
            isLine,
            lineIndex,
        } = datas;
        const controlPoses = datas.controlPoses as ControlPose[];
        const length = controlPoses.filter(({ virtual }) => virtual).length;
        const {
            roundClickable = true,
        } = moveable.props;

        if (isDouble && roundClickable) {
            if (isControl && (roundClickable === true || roundClickable === "control")) {
                removeBorderRadius(controlPoses, controlIndex);
            } else if (isLine && (roundClickable === true || roundClickable === "line")) {
                const [distX, distY] = calculatePointerDist(moveable, e);

                addBorderRadiusByLine(controlPoses, lineIndex, distX, distY);
            }

            if (length !== controlPoses.filter(({ virtual }) => virtual).length) {
                triggerRoundEvent(
                    moveable,
                    e,
                    [0, 0],
                    [0, 0],
                    controlPoses,
                );
            }
        }
        const params = fillEndParams<OnRoundEnd>(moveable, e, {});

        triggerEvent(moveable, "onRoundEnd", params);
        state.borderRadiusState = "";
        return params;
    },
    dragGroupControlStart(moveable: MoveableGroupInterface<RoundableProps, RoundableState>, e: any) {
        const result = this.dragControlStart(moveable, e);

        if (!result) {
            return false;
        }

        const moveables = moveable.moveables;
        const targets = moveable.props.targets!;
        const events = fillChildEvents(moveable, "roundable", e);

        const nextParams: OnRoundGroupStart = {
            targets: moveable.props.targets!,
            events: events.map((ev, i) => {
                return {
                    ...ev,
                    target: targets[i],
                    moveable: moveables[i],
                    currentTarget: moveables[i],
                };
            }),
            ...result,
        };

        triggerEvent(moveable, "onRoundGroupStart", nextParams);
        return result;
    },
    dragGroupControl(moveable: MoveableGroupInterface<RoundableProps, RoundableState>, e: any) {
        const result = this.dragControl(moveable, e);


        if (!result) {
            return false;
        }

        const moveables = moveable.moveables;
        const targets = moveable.props.targets!;
        const events = fillChildEvents(moveable, "roundable", e);

        const nextParams: OnRoundGroup = {
            targets: moveable.props.targets!,
            events: events.map((ev, i) => {
                return {
                    ...ev,
                    target: targets[i],
                    moveable: moveables[i],
                    currentTarget: moveables[i],
                    ...fillCSSObject({
                        borderRadius: result.borderRadius,
                    }, ev),
                };
            }),
            ...result,
        };

        triggerEvent(moveable, "onRoundGroup", nextParams);
        return nextParams;
    },
    dragGroupControlEnd(moveable: MoveableGroupInterface<RoundableProps, RoundableState>, e: any) {
        const moveables = moveable.moveables;
        const targets = moveable.props.targets!;
        const events = fillChildEvents(moveable, "roundable", e);

        catchEvent(moveable, "onRound", parentEvent => {
            const nextParams: OnRoundGroup = {
                targets: moveable.props.targets!,
                events: events.map((ev, i) => {
                    return {
                        ...ev,
                        target: targets[i],
                        moveable: moveables[i],
                        currentTarget: moveables[i],
                        ...fillCSSObject({
                            borderRadius: parentEvent.borderRadius,
                        }, ev),
                    };
                }),
                ...parentEvent,
            };
            triggerEvent(moveable, "onRoundGroup", nextParams);
        });
        const result = this.dragControlEnd(moveable, e);

        if (!result) {
            return false;
        }
        const nextParams: OnRoundGroupEnd = {
            targets: moveable.props.targets!,
            events: events.map((ev, i) => {
                return {
                    ...ev,
                    target: targets[i],
                    moveable: moveables[i],
                    currentTarget: moveables[i],
                    lastEvent: ev.datas?.lastEvent,
                };
            }),
            ...result,
        };

        triggerEvent(moveable, "onRoundGroupEnd", nextParams);
        return nextParams;
    },
    unset(moveable: MoveableManagerInterface<RoundableProps, RoundableState>) {
        moveable.state.borderRadiusState = "";
    },
};
/**
 * Whether to show and drag or double click border-radius, (default: false)
 * @name Moveable.Roundable#roundable
 * @example
 * import Moveable from "moveable";
 *
 * const moveable = new Moveable(document.body, {
 *     roundable: true,
 *     roundRelative: false,
 * });
 * moveable.on("roundStart", e => {
 *     console.log(e);
 * }).on("round", e => {
 *     e.target.style.borderRadius = e.borderRadius;
 * }).on("roundEnd", e => {
 *     console.log(e);
 * });
 */
/**
 * % Can be used instead of the absolute px
 * @name Moveable.Roundable#roundRelative
 * @example
 * import Moveable from "moveable";
 *
 * const moveable = new Moveable(document.body, {
 *     roundable: true,
 *     roundRelative: false,
 * });
 * moveable.on("roundStart", e => {
 *     console.log(e);
 * }).on("round", e => {
 *     e.target.style.borderRadius = e.borderRadius;
 * }).on("roundEnd", e => {
 *     console.log(e);
 * });
 */
/**
 * Minimum number of round controls. It moves in proportion by control. [horizontal, vertical] (default: [0, 0])
 * @name Moveable.Roundable#minRoundControls
 * @example
 * import Moveable from "moveable";
 *
 * const moveable = new Moveable(document.body, {
 *     roundable: true,
 *     roundRelative: false,
 *     minRoundControls: [0, 0],
 * });
 * moveable.minRoundControls = [1, 0];
 */
/**
 * Maximum number of round controls. It moves in proportion by control. [horizontal, vertical] (default: [4, 4])
 * @name Moveable.Roundable#maxRoundControls
 * @example
 * import Moveable from "moveable";
 *
 * const moveable = new Moveable(document.body, {
 *     roundable: true,
 *     roundRelative: false,
 *     maxRoundControls: [4, 4],
 * });
 * moveable.maxRoundControls = [1, 0];
 */
/**
 * Whether you can add/delete round controls by double-clicking a line or control.
 * @name Moveable.Roundable#roundClickable
 * @default true
 * @example
 * import Moveable from "moveable";
 *
 * const moveable = new Moveable(document.body, {
 *     roundable: true,
 *     roundRelative: false,
 *     roundClickable: true,
 * });
 * moveable.roundClickable = false;
 */

/**
 * Whether to show a round control that does not actually exist as a shadow
 * @name Moveable.Roundable#isDisplayShadowRoundControls
 * @default false
 * @example
 * import Moveable from "moveable";
 *
 * const moveable = new Moveable(document.body, {
 *     roundable: true,
 *     isDisplayShadowRoundControls: false,
 * });
 * moveable.isDisplayShadowRoundControls = true;
 */


/**
 * The padding value of the position of the round control
 * @name Moveable.Roundable#roundPadding
 * @default false
 * @example
 * import Moveable from "moveable";
 *
 * const moveable = new Moveable(document.body, {
 *     roundable: true,
 *     roundPadding: 0,
 * });
 * moveable.roundPadding = 15;
 */

/**
 * When drag start the clip area or controls, the `roundStart` event is called.
 * @memberof Moveable.Roundable
 * @event roundStart
 * @param {Moveable.Roundable.OnRoundStart} - Parameters for the `roundStart` event
 * @example
 * import Moveable from "moveable";
 *
 * const moveable = new Moveable(document.body, {
 *     roundable: true,
 *     roundRelative: false,
 * });
 * moveable.on("roundStart", e => {
 *     console.log(e);
 * }).on("round", e => {
 *     e.target.style.borderRadius = e.borderRadius;
 * }).on("roundEnd", e => {
 *     console.log(e);
 * });
 */


/**
 * When drag or double click the border area or controls, the `round` event is called.
 * @memberof Moveable.Roundable
 * @event round
 * @param {Moveable.Roundable.OnRound} - Parameters for the `round` event
 * @example
  * import Moveable from "moveable";
 *
 * const moveable = new Moveable(document.body, {
 *     roundable: true,
 *     roundRelative: false,
 * });
 * moveable.on("roundStart", e => {
 *     console.log(e);
 * }).on("round", e => {
 *     e.target.style.borderRadius = e.borderRadius;
 * }).on("roundEnd", e => {
 *     console.log(e);
 * });
 */


/**
 * When drag end the border area or controls, the `roundEnd` event is called.
 * @memberof Moveable.Roundable
 * @event roundEnd
 * @param {Moveable.Roundable.onRoundEnd} - Parameters for the `roundEnd` event
 * @example
  * import Moveable from "moveable";
 *
 * const moveable = new Moveable(document.body, {
 *     roundable: true,
 *     roundRelative: false,
 * });
 * moveable.on("roundStart", e => {
 *     console.log(e);
 * }).on("round", e => {
 *     e.target.style.borderRadius = e.borderRadius;
 * }).on("roundEnd", e => {
 *     console.log(e);
 * });
 */


/**
 * When drag start the clip area or controls, the `roundGroupStart` event is called.
 * @memberof Moveable.Roundable
 * @event roundGroupStart
 * @param {Moveable.Roundable.OnRoundGroupStart} - Parameters for the `roundGroupStart` event
 * @example
 * import Moveable from "moveable";
 *
 * const moveable = new Moveable(document.body, {
 *     targets: [target1, target2, target3],
 *     roundable: true,
 * });
 * moveable.on("roundGroupStart", e => {
 *     console.log(e.targets);
 * }).on("roundGroup", e => {
 *   e.events.forEach(ev => {
 *       ev.target.style.cssText += ev.cssText;
 *   });
 * }).on("roundGroupEnd", e => {
 *     console.log(e);
 * });
 */


/**
 * When drag or double click the border area or controls, the `roundGroup` event is called.
 * @memberof Moveable.Roundable
 * @event roundGroup
 * @param {Moveable.Roundable.OnRoundGroup} - Parameters for the `roundGroup` event
 * @example
 * import Moveable from "moveable";
 *
 * const moveable = new Moveable(document.body, {
 *     targets: [target1, target2, target3],
 *     roundable: true,
 * });
 * moveable.on("roundGroupStart", e => {
 *     console.log(e.targets);
 * }).on("roundGroup", e => {
 *   e.events.forEach(ev => {
 *       ev.target.style.cssText += ev.cssText;
 *   });
 * }).on("roundGroupEnd", e => {
 *     console.log(e);
 * });
 */


/**
 * When drag end the border area or controls, the `roundGroupEnd` event is called.
 * @memberof Moveable.Roundable
 * @event roundGroupEnd
 * @param {Moveable.Roundable.onRoundGroupEnd} - Parameters for the `roundGroupEnd` event
 * @example
 * import Moveable from "moveable";
 *
 * const moveable = new Moveable(document.body, {
 *     targets: [target1, target2, target3],
 *     roundable: true,
 * });
 * moveable.on("roundGroupStart", e => {
 *     console.log(e.targets);
 * }).on("roundGroup", e => {
 *     e.events.forEach(ev => {
 *         ev.target.style.cssText += ev.cssText;
 *     });
 * }).on("roundGroupEnd", e => {
 *     console.log(e);
 * });
 */