packages/react-moveable/src/InitialMoveable.tsx

import * as React from "react";
import {
    Able, MoveableInterface, GroupableProps, MoveableDefaultProps,
    IndividualGroupableProps, MoveableManagerInterface, MoveableRefTargetsResultType,
    MoveableTargetGroupsType, BeforeRenderableProps, RenderableProps, MoveableManagerState,
} from "./types";
import MoveableManager from "./MoveableManager";
import MoveableGroup from "./MoveableGroup";
import { ref, withMethods, prefixCSS } from "framework-utils";
import { find, getKeys, IObject, isArray, isString } from "@daybrush/utils";
import { MOVEABLE_METHODS, PREFIX, MOVEABLE_CSS } from "./consts";
import Default from "./ables/Default";
import Groupable from "./ables/Groupable";
import DragArea from "./ables/DragArea";
import { styled } from "react-css-styled";
import { getRefTargets } from "./utils";
import IndividualGroupable from "./ables/IndividualGroupable";
import MoveableIndividualGroup from "./MoveableIndividualGroup";
import ChildrenDiffer from "@egjs/children-differ";

function getElementTargets(
    refTargets: MoveableRefTargetsResultType,
    selectorMap: IObject<Array<HTMLElement | SVGElement>>,
): Array<SVGElement | HTMLElement> {
    const elementTargets: Array<SVGElement | HTMLElement> = [];

    refTargets.forEach(target => {
        if (!target) {
            return;
        }
        if (isString(target)) {
            if (selectorMap[target]) {
                elementTargets.push(...selectorMap[target]);
            }
            return;
        }
        if (isArray(target)) {
            elementTargets.push(...getElementTargets(target, selectorMap));
        } else {
            elementTargets.push(target);
        }
    });

    return elementTargets;
}

function getTargetGroups(
    refTargets: MoveableRefTargetsResultType,
    selectorMap: IObject<Array<HTMLElement | SVGElement>>,
) {
    const targetGroups: MoveableTargetGroupsType = [];

    refTargets.forEach(target => {
        if (!target) {
            return;
        }
        if (isString(target)) {
            if (selectorMap[target]) {
                targetGroups.push(...selectorMap[target]);
            }
            return;
        }
        if (isArray(target)) {
            targetGroups.push(getTargetGroups(target, selectorMap));
        } else {
            targetGroups.push(target);
        }
    });

    return targetGroups;
}

function compareRefTargets(
    prevRefTargets: MoveableRefTargetsResultType,
    nextRefTargets: MoveableRefTargetsResultType,
): boolean {
    return (prevRefTargets.length !== nextRefTargets.length) || prevRefTargets.some((target, i) => {
        const nextTarget = nextRefTargets[i];

        if (!target && !nextTarget) {
            return false;
        } else if (target != nextTarget) {
            if (isArray(target) && isArray(nextTarget)) {
                return compareRefTargets(target, nextTarget);
            }
            return true;
        }
        return false;
    });
}

type DefaultAbles = GroupableProps & IndividualGroupableProps & BeforeRenderableProps & RenderableProps;

export class InitialMoveable<T = {}>
    extends React.PureComponent<MoveableDefaultProps & DefaultAbles & T> {
    public static defaultAbles: readonly Able<any>[] = [];
    public static customStyledMap: Record<string, any> = {};
    public static defaultStyled: any = null;
    public static makeStyled() {
        const cssMap: IObject<boolean> = {};

        const ables = this.getTotalAbles();
        ables.forEach(({ css }: Able) => {
            if (!css) {
                return;
            }
            css.forEach(text => {
                cssMap[text] = true;
            });
        });
        const style = getKeys(cssMap).join("\n");

        this.defaultStyled = styled("div", prefixCSS(PREFIX, MOVEABLE_CSS + style));
    }
    public static getTotalAbles(): Able[] {
        return [Default, Groupable, IndividualGroupable, DragArea, ...this.defaultAbles];
    }
    @withMethods(MOVEABLE_METHODS)
    public moveable!: MoveableManager | MoveableGroup | MoveableIndividualGroup;
    public refTargets: MoveableRefTargetsResultType = [];
    public selectorMap: IObject<Array<HTMLElement | SVGElement>> = {};
    private _differ: ChildrenDiffer<HTMLElement | SVGElement> = new ChildrenDiffer();
    private _elementTargets: Array<HTMLElement | SVGElement> = [];
    private _tmpRefTargets: MoveableRefTargetsResultType = [];
    private _tmpSelectorMap: IObject<Array<HTMLElement | SVGElement>> = {};
    private _onChangeTargets: (() => void) | null = null;
    public render() {
        const moveableContructor = (this.constructor as typeof InitialMoveable);

        if (!moveableContructor.defaultStyled) {
            moveableContructor.makeStyled();
        }
        const {
            ables: userAbles,
            props: userProps,
            ...props
        } = this.props;
        const [
            refTargets,
            nextSelectorMap,
        ] = this._updateRefs(true);
        const elementTargets = getElementTargets(refTargets, nextSelectorMap);

        let isGroup = elementTargets.length > 1;
        const totalAbles = moveableContructor.getTotalAbles();
        const ables = [
            ...totalAbles,
            ...(userAbles as any || []),
        ];
        const nextProps = {
            ...props,
            ...(userProps || {}),
            ables,
            cssStyled: moveableContructor.defaultStyled,
            customStyledMap: moveableContructor.customStyledMap,
        };

        this._elementTargets = elementTargets;

        let firstRenderState: MoveableManagerState | null = null;
        const prevMoveable = this.moveable;


        const persistData = props.persistData;

        if (persistData?.children) {
            isGroup = true;
        }
        // Even one child is treated as a group if individualGroupable is enabled. #867
        if (props.individualGroupable) {
            return <MoveableIndividualGroup key="individual-group" ref={ref(this, "moveable")}
                {...nextProps}
                target={null}
                targets={elementTargets}
            />;
        }
        if (isGroup) {
            const targetGroups = getTargetGroups(refTargets, nextSelectorMap);

            // manager
            if (prevMoveable && !prevMoveable.props.groupable && !(prevMoveable.props as any).individualGroupable) {
                const target = prevMoveable.props.target!;

                if (target && elementTargets.indexOf(target) > -1) {
                    firstRenderState = { ...prevMoveable.state };
                }
            }

            return <MoveableGroup key="group" ref={ref(this, "moveable")}
                {...nextProps}
                {...props.groupableProps ?? {}}
                target={null}
                targets={elementTargets}
                targetGroups={targetGroups}
                firstRenderState={firstRenderState}
            />;
        } else {
            const target = elementTargets[0];
            // manager
            if (prevMoveable && (prevMoveable.props.groupable || (prevMoveable.props as any).individualGroupable)) {
                const moveables = (prevMoveable as MoveableGroup | MoveableIndividualGroup).moveables || [];
                const prevTargetMoveable = find(moveables, mv => mv.props.target === target);

                if (prevTargetMoveable) {
                    firstRenderState = { ...prevTargetMoveable.state };
                }
            }

            return <MoveableManager<any> key="single" ref={ref(this, "moveable")}
                {...nextProps}
                target={target}
                firstRenderState={firstRenderState} />;
        }
    }
    public componentDidMount() {
        this._checkChangeTargets();
    }
    public componentDidUpdate() {
        this._checkChangeTargets();
    }
    public componentWillUnmount() {
        this.selectorMap = {};
        this.refTargets = [];
    }
    /**
     * Get targets set in moveable through target or targets of props.
     * @method Moveable#getTargets
     * @example
     * import Moveable from "moveable";
     *
     * const moveable = new Moveable(document.body, {
     *    target: [targetRef, ".target", document.querySelectorAll(".target")],
     * });
     *
     * console.log(moveable.getTargets());
     */
    public getTargets() {
        return this.moveable?.getTargets() ?? [];
    }
    /**
     * If the element list corresponding to the selector among the targets is changed, it is updated.
     * @method Moveable#updateSelectors
     * @example
     * import Moveable from "moveable";
     *
     * const moveable = new Moveable(document.body, {
     *    target: ".target",
     * });
     *
     * moveable.updateSelectors();
     */
    public updateSelectors() {
        this.selectorMap = {};
        this._updateRefs();
        this.forceUpdate();
    }
    /**
     * User changes target and waits for target to change.
     * @method Moveable#waitToChangeTarget
     * @story combination-with-other-components--components-selecto
     * @example
     * document.querySelector(".target").addEventListener("mousedown", e => {
     *   moveable.waitToChangeTarget().then(() => {
     *      moveable.dragStart(e, e.currentTarget);
     *   });
     *   moveable.target = e.currentTarget;
     * });
     */
    public waitToChangeTarget(): Promise<void> {
        // let resolvePromise: (e: OnChangeTarget) => void;

        // this._onChangeTargets = () => {
        //     this._onChangeTargets = null;
        //     resolvePromise({
        //         moveable: this.getManager(),
        //         targets: this._elementTargets,
        //     });
        // };

        // return new Promise<OnChangeTarget>(resolve => {
        //     resolvePromise = resolve;
        // });
        let resolvePromise: () => void;

        this._onChangeTargets = () => {
            this._onChangeTargets = null;
            resolvePromise();
        };

        return new Promise(resolve => {
            resolvePromise = resolve;
        });
    }
    public waitToChangeTargets(): Promise<void> {
        return this.waitToChangeTarget();
    }
    public getManager(): MoveableManagerInterface<any, any> {
        return this.moveable;
    }
    public getMoveables(): MoveableManagerInterface[] {
        return this.moveable.getMoveables();
    }
    public getDragElement(): HTMLElement | SVGElement | null | undefined {
        return this.moveable.getDragElement();
    }
    private _updateRefs(isRender?: boolean) {
        const prevRefTargets = this.refTargets;
        const nextRefTargets = getRefTargets((this.props.target || this.props.targets) as any);
        const isBrowser = typeof document !== "undefined";

        let isUpdate = compareRefTargets(prevRefTargets, nextRefTargets);
        const selectorMap = this.selectorMap;
        const nextSelectorMap: IObject<Array<HTMLElement | SVGElement>> = {};

        this.refTargets.forEach(function updateSelectorMap(target) {
            if (isString(target)) {
                const selectorTarget = selectorMap[target];

                if (selectorTarget) {
                    nextSelectorMap[target] = selectorMap[target];
                } else if (isBrowser) {
                    isUpdate = true;
                    nextSelectorMap[target] = [].slice.call(document.querySelectorAll(target));
                }
            } else if (isArray(target)) {
                target.forEach(updateSelectorMap);
            }
        });

        this._tmpRefTargets = nextRefTargets;
        this._tmpSelectorMap = nextSelectorMap;

        return [
            nextRefTargets,
            nextSelectorMap,
            !isRender && isUpdate,
        ] as const;
    }
    private _checkChangeTargets() {
        this.refTargets = this._tmpRefTargets;
        this.selectorMap = this._tmpSelectorMap;

        const { added, removed } = this._differ.update(this._elementTargets);
        const isTargetChanged = added.length || removed.length;

        if (isTargetChanged) {
            this.props.onChangeTargets?.({
                moveable: this.moveable,
                targets: this._elementTargets,
            });
            this._onChangeTargets?.();
        }
        const [
            refTargets,
            selectorMap,
            isUpdate,
        ] = this._updateRefs();

        this.refTargets = refTargets;
        this.selectorMap = selectorMap;

        if (isUpdate) {
            this.forceUpdate();
        }
    }
}
export interface InitialMoveable<T = {}>
    extends React.PureComponent<MoveableDefaultProps & DefaultAbles & T>,
    MoveableInterface {
    setState(state: any, callback?: () => any): any;
    forceUpdate(callback?: () => any): any;
}