import { Subject, Subscription, Observable } from 'rxjs';
import {
BucketDragDrop,
IBucketContainerRef,
IBucketItemRef,
SelectionBoundary,
BucketCloneModel,
BucketDropBeforeParams,
BucketDOMRectBound,
BucketGroupNameType
} from './types';
import { createUid, isIntersect } from '../utils';
/**
*
*/
type BucketDropBeforeCallback = (params: BucketDropBeforeParams) => any;
type BucketDropBeforeEmitter = (params: { changeList: any[] }) => any;
/**
* 한개의 버킷 컨테이너.
* - 동일 그룹명끼지 모델 전송이 가능하다.
* - sender 역할의 컨테이너는 receiver 로 전달만 가능하고, 자체 소팅은 지원하지 않는다.
* - receiver 역할의 컨테이너는 sender 로 부터 전달을 받을 수 있고, 자체 소팅을 지원한다.
*
* @export
* @class JdBucketContainerRef
* @implements {IBucketContainerRef}
*/
export class JdBucketContainerRef<TM = any> implements IBucketContainerRef<TM> {
constructor() {
this.uid = createUid();
}
protected uid: string = '';
protected itemRefMap: Map<string, IBucketItemRef> = new Map();
protected bucketList: TM[] = [];
protected bucketGroupName: BucketGroupNameType = '';
protected bucketMax: number = -1;
protected bucketIsReceiver: boolean = false;
protected bucketIsMultiple: boolean = false;
protected elDragContainer: Element | null = null;
protected hookDropBefore: BucketDropBeforeCallback | null = null;
protected dragItemRefs: IBucketItemRef[] = [];
protected subjectDropped: Subject<BucketDragDrop> = new Subject();
protected subjectChangeState: Subject<IBucketContainerRef> = new Subject();
protected lazyChangeStateTimer: any | null = null;
protected lazyChangeStateDelay: number = 0;
CLONE_FLAG_KEY = '__cloneFlag';
CLONE_FLAG_VALUE = 'CLONE_FLAG_VALUE';
/**
* 그룹명
* @readonly
* @type {BucketGroupNameType}
*/
get groupName(): BucketGroupNameType {
return this.bucketGroupName;
}
/**
* 컨테이너에 담을 수 있는 최대 수
* @readonly
* @type {number}
*/
get max(): number {
return this.bucketMax;
}
/**
* 해당 컨테이너가 receiver 인지 여부.
* @readonly
* @type {boolean}
*/
get isReceiver(): boolean {
return this.bucketIsReceiver;
}
/**
* 멀티 dnd 지원 여부.
* @readonly
* @type {boolean}
*/
get isMultiple(): boolean {
return this.bucketIsMultiple;
}
/**
* 현재 컨테이너가 포함한 모델 갯수가 최대 갯수에 도달했는지 여부.
* @readonly
* @type {boolean}
*/
get isMaximum(): boolean {
return this.bucketMax === -1 ? false : this.bucketMax <= this.bucketList.length;
}
/**
* draggable DOM
* @readonly
* @type {(Element | null)}
*/
get elContainer(): Element | null {
return this.elDragContainer;
}
/**
* 지정한 hookDropBefore 함수.
* @readonly
* @type {(BucketDropBeforeCallback | null)}
*/
get onDropBefore(): BucketDropBeforeCallback | null {
return this.hookDropBefore;
}
/**
* 그룹명 지정
* @param {BucketGroupNameType} groupName
*/
setGroupName(groupName: BucketGroupNameType) {
this.bucketGroupName = groupName;
this.dispatchChangeState();
}
/**
* receiver 인지 지정
* @param {boolean} is
*/
setReceiver(is: boolean) {
this.bucketIsReceiver = !!is;
this.dispatchChangeState();
}
/**
* 멀티플 지원여부 지정
* @param {boolean} is
*/
setMultiple(is: boolean) {
this.bucketIsMultiple = !!is;
this.dispatchChangeState();
}
/**
* 버킷에 담을 수 있는 최대 수 지정
* @param {number} [max=-1]
*/
setMax(max: number = -1) {
if (isNaN(max)) {
this.bucketMax = -1;
} else {
this.bucketMax = Math.max(-1, max);
}
this.dispatchChangeState();
}
/**
* 모델 목록
* @param {*} list
*/
setList(list: any): void {
this.bucketList = list;
this.dispatchChangeState();
}
/**
* draggable DOM 참조 지정
* @param {Element} element
*/
setElContainer(element: Element): void {
this.elDragContainer = element;
}
/**
* 컨테이너 상태 변경 알림을 delay 만큼 느슨하게 하도록 지정.
* @param {number} delay
*/
setLazyStateChangeDelay(delay: number): void {
this.lazyChangeStateDelay = isNaN(delay) ? 0 : delay;
}
/**
* sender 컨테이너에서 receiver 컨테이너로 드랍을 하고,
* list 를 merge 하기 직전 실행될 (validate, filter ... 를 직접 할) 함수 지정.
* @param {BucketDropBeforeCallback} handle
*/
setDropBefore(handle: BucketDropBeforeCallback): void {
this.hookDropBefore = handle;
}
/**
* 해당 컨테이너의 uid 반환
* @returns {string}
*/
getUid(): string {
return this.uid;
}
/**
* 가지고 있는 모델 목록 반환
*/
getList(): TM[] {
return this.bucketList;
}
/**
* 옵저버: 컨테이너 상태 변경
* @returns {Observable<IBucketContainerRef>}
*/
observeChangeState(): Observable<IBucketContainerRef> {
return this.subjectChangeState.asObservable();
}
/**
* 옵저버: 컨테이너에 드랍
* @returns {Observable<BucketDragDrop>}
*/
observeDropped(): Observable<BucketDragDrop> {
return this.subjectDropped.asObservable();
}
/**
* 알림: 컨테이너 상태 변경
* setLazyStateChangeDelay 가 지정되어 있다면 lazy 하게 알림, 그렇지 않다면 바로 알림.
*/
dispatchChangeState(): void {
if (this.lazyChangeStateTimer) {
clearTimeout(this.lazyChangeStateTimer);
this.lazyChangeStateTimer = null;
}
if (0 < this.lazyChangeStateDelay) {
this.lazyChangeStateTimer = setTimeout(() => {
this.subjectChangeState.next(this);
}, this.lazyChangeStateDelay);
} else {
this.subjectChangeState.next(this);
}
}
/**
* 알림: 컨테이너에 드랍
* @param {BucketDragDrop} params
*/
dispatchDropped(params: BucketDragDrop): void {
const { toContainer, fromContainer } = params;
if (toContainer && toContainer !== fromContainer && toContainer.isReceiver) {
this.subjectDropped.next(params);
}
}
/**
* 드래깅 버킷 아이템 추가.
* (드래깅 - 드래그 아이템으로 선택, 드래그 중인 것)
* @param {IBucketItemRef} itemRef
*/
addDragItem(itemRef: IBucketItemRef): void {
if (this.findDragItemIndex(itemRef) === -1) {
this.dragItemRefs.push(itemRef);
}
itemRef.setSelected(true);
this.dispatchChangeState();
}
/**
* 지정된 boundary 영역에 hit 되는 버킷 아이템을 찾아서 추가
* @param {SelectionBoundary} boundary
*/
addDragItemToBoundary(boundary: SelectionBoundary) {
const itemRefs = this.itemRefMap;
const boundX = { start: boundary.x, end: boundary.x + boundary.w };
const boundY = { start: boundary.y, end: boundary.y + boundary.h };
Array.from(itemRefs.values()).forEach(itemRef => {
const itemBound = itemRef.getElBound() as BucketDOMRectBound;
const itemBoundX = { start: itemBound.x, end: itemBound.x + itemBound.width };
const itemBoundY = { start: itemBound.y, end: itemBound.y + itemBound.height };
const isHitX = isIntersect(boundX, itemBoundX);
const isHitY = isIntersect(boundY, itemBoundY);
const isHit = isHitX && isHitY;
if (isHit) {
this.addDragItem(itemRef);
} else {
this.removeDragItem(itemRef);
}
});
}
/**
* 드래깅 버킷 아이템 제거
* @param {IBucketItemRef} itemRef
*/
removeDragItem(itemRef: IBucketItemRef): void {
const index = this.findDragItemIndex(itemRef);
if (index !== -1) {
this.dragItemRefs.splice(index, 1);
}
itemRef.setSelected(false);
this.dispatchChangeState();
}
/**
* 드래깅 버킷 아이템으로 지정
* @param {IBucketItemRef} itemRef
*/
setDragItem(itemRef: IBucketItemRef): void {
this.dragItemRefs.forEach(itemRef => {
itemRef.setSelected(false);
});
this.dragItemRefs = [itemRef];
itemRef.setSelected(true);
this.dispatchChangeState();
}
/**
* 드래깅 버킷 아이템 반환
* @returns {IBucketItemRef[]}
*/
getDragItems(): IBucketItemRef[] {
return this.dragItemRefs;
}
/**
* 드래깅 버킷 아이템 모두 제거
*/
flushDragItem(): void {
this.dragItemRefs.forEach(itemRef => {
itemRef.setSelected(false);
});
this.dragItemRefs = [];
this.dispatchChangeState();
}
/**
* 드래깅으로 등록된 버킷 아이템 중 지정된 itemRef 와 일치하는것 index 반환.
* @param {IBucketItemRef} itemRef
* @returns {number}
*/
findDragItemIndex(itemRef: IBucketItemRef): number {
return this.dragItemRefs.findIndex(compare => compare === itemRef);
}
/**
* list 중 지정된 itemRef 의 model 과 일치하는것 index 반환.
* @param {IBucketItemRef} itemRef
* @returns {number}
*/
findItemRefIndex(itemRef: IBucketItemRef): number {
const list = this.getList() || [];
return list.findIndex(model => model === itemRef.model);
}
/**
* fromContainer 와 그룹명이 같은지 여부.
* @param {JdBucketContainerRef} fromContainer
* @returns {boolean}
*/
isGroupSame(fromContainer: JdBucketContainerRef): boolean {
if (!fromContainer) return false;
return this.groupName === fromContainer.groupName;
}
/**
* 해당 컨테이너가 드래깅 중이 아이템을 넣을 수 있느지 여부.
* fromContainer 의 정보와 해당 컨테이너의 조건에 따라 드랍이 가능한지 여부를 판단함.
* @param {JdBucketContainerRef} fromContainer
* @returns {boolean}
*/
isGroupInsertable(fromContainer: JdBucketContainerRef): boolean {
if (!fromContainer) return false;
const is =
this !== fromContainer &&
fromContainer.isReceiver !== true &&
this.isReceiver === true &&
this.groupName === fromContainer.groupName &&
this.isMaximum === false;
return is;
}
/**
* list 의 index 아이템을 제거.
* @param {number} index
*/
removeByIndex(index: number): void {
if (!isNaN(index) && 0 <= index && index < this.bucketList.length) {
this.bucketList.splice(index, 1);
this.dispatchChangeState();
}
}
/**
* list 중 model 을 찾아서 동일한 model 의 아이템을 제거.
* @param {*} model
*/
removeByModel(model: any): void {
const index = this.bucketList.findIndex(compare => compare === model);
if (index !== -1) {
this.removeByIndex(index);
}
}
/**
* element 로 버킷 아이템을 찾아서 반환
* @param {Element} element
* @returns {IBucketItemRef}
*/
findItemRefByElement(element: Element): IBucketItemRef {
const finded = Array.from(this.itemRefMap.values()).find(ref => {
return ref.elContainer === element;
}) as IBucketItemRef;
return finded;
}
/**
* 버킷 아이템 등록.
* @param {IBucketItemRef} itemRef
*/
joinItemRef(itemRef: IBucketItemRef): void {
this.itemRefMap.set(itemRef.getUid(), itemRef);
}
/**
* 버킷 아이템 등록 해제.
* @param {IBucketItemRef} itemRef
*/
unjoinItemRef(itemRef: IBucketItemRef): void {
this.itemRefMap.delete(itemRef.getUid());
this.removeDragItem(itemRef);
}
/**
* 등록된 버킷 아이템 맵.
* @returns {Map<string, IBucketItemRef>}
*/
getItemRefs(): Map<string, IBucketItemRef> {
return this.itemRefMap;
}
/**
* draggable clone 시 치환할 모델.
* draggable 에서 드랍을 하고 모델이 합쳐지기 직전 onClone 이 발생되는데, 이 때 이동될 모델을 치환할 수 있다.
* onClone 시 버킷쪽에서 멀티플 선택한 모델 목록으로 합쳐버려도 되나,
* dropBefore 를 통해 직접 값을 filter(예: 중복 id 는 넣지 않음, 어떤 type 은 제외하고 넣음 등) 하는 기능을 지원하려면,
* onClone 시 플래그 모델로 치환 시키고 onDrop 타이밍에 플래그 모델을 제거,
* dropBefore 의 유무/응답에 따라 버킷쪽에서 선택된 모델이나, dropBefore 에서 정제된 목록으로 합친다.
* @returns {BucketCloneModel}
*/
createCloneModel(): BucketCloneModel {
return { [this.CLONE_FLAG_KEY]: this.CLONE_FLAG_VALUE };
}
/**
* fromContainer 의 드래깅 중인(드랍한) 아이템을 insertIndex 에 현재 리스트에 합친다.
* - dropBefore 가 지정된 경우 사용자 응답을 기다리고, 응답이 배열인 경우는 응답한 배열로 합친다.
* 그렇지 않은 경우 응답값이 true 에 해당하는 경우만 드랍한 아이템으로 합친다.
* 그 외는 무시하고 합치지 않는다.
* fnEmitter 콜백의 경우 UI 단에서는 dropBefore 에 따라 선/후행 업데이트를 해야할 수 있어서
* list 가 변경된 상태를 UI 단에서 알아야하는 경우 콜백을 해준다.
* @param {number} insertIndex
* @param {IBucketContainerRef} fromContainer
* @param {BucketDropBeforeEmitter} [fnEmitter]
*/
async mergeToDrop(
insertIndex: number,
fromContainer: IBucketContainerRef,
fnEmitter?: BucketDropBeforeEmitter
): Promise<void> {
if (!fromContainer) return;
const emitter = fnEmitter && typeof fnEmitter === 'function' ? fnEmitter : () => {};
const interceptDropBefore = this.onDropBefore || null;
const dropItemRefs = fromContainer.getDragItems() || [];
const dropList = dropItemRefs.map(item => item.model);
const mergeList = this.getMergeCurrentList();
const insertList = this.getMergeInsertList(dropList);
let finalInsertList = null;
if (interceptDropBefore) {
this.setList(mergeList);
emitter({ changeList: mergeList });
const interceptResult = await interceptDropBefore({
fromContainer,
toContainer: this,
itemRefs: dropItemRefs
});
if (interceptResult) {
if (interceptResult.constructor === Array) {
finalInsertList = interceptResult;
} else {
finalInsertList = insertList;
}
}
} else {
finalInsertList = insertList;
}
fromContainer.flushDragItem();
if (finalInsertList !== null) {
mergeList.splice(insertIndex, 0, ...finalInsertList);
this.setList(mergeList);
emitter({ changeList: mergeList });
}
}
/**
* merge 할 때 현재 가지고 있는 목록을 정제하여 반환.
* @protected
* @returns {TM[]}
*/
protected getMergeCurrentList(): TM[] {
const changeList = [...this.getList()];
const clonedIndex = changeList.findIndex((item: any) => {
return item[this.CLONE_FLAG_KEY] === this.CLONE_FLAG_VALUE;
});
if (clonedIndex !== -1) changeList.splice(clonedIndex, 1);
return changeList;
}
/**
* merge 할 때 합쳐져야할 대상 목록을 정제하여 반환.
* @protected
* @param {TM[]} addList
* @returns {TM[]}
*/
protected getMergeInsertList(addList: TM[]): TM[] {
const currentLen = this.getList().length;
const addLen = addList.length;
const expectMax = this.max || -1;
const isOverflow = expectMax !== -1 && expectMax < currentLen + addLen;
const resultList = isOverflow ? addList.slice(0, expectMax - currentLen + 1) : addList;
return resultList;
}
/**
* 파기
*/
destroy(): void {
try {
if (this.subjectDropped) {
this.subjectDropped.unsubscribe();
}
// ...
} catch (err) {
console.error(err);
}
}
}