import EventEmitter from "@scena/event-emitter";
import Gesto, { OnDrag } from "gesto";
import { InjectResult } from "css-styled";
import { Properties } from "framework-utils";
import {
isObject,
camelize,
IObject,
addEvent,
removeEvent,
isArray,
isString,
between,
splitUnit,
isFunction,
getWindow,
getDocument,
isNode,
} from "@daybrush/utils";
import { diff } from "@egjs/children-differ";
import DragScroll from "@scena/dragscroll";
import KeyController, { KeyControllerEvent, getCombi } from "keycon";
import {
getAreaSize,
getOverlapPoints,
isInside,
fitPoints,
} from "overlap-area";
import { getDistElementMatrix, calculateMatrixDist, createMatrix } from "css-to-mat";
import {
createElement,
h,
getClient,
diffValue,
getRect,
getDefaultElementRect,
passTargets,
elementFromPoint,
filterDuplicated,
getLineSize,
} from "./utils";
import {
SelectoOptions,
SelectoProperties,
OnDragEvent,
SelectoEvents,
Rect,
BoundContainer,
SelectedTargets,
SelectedTargetsWithRect,
InnerParentInfo,
ElementType,
OnDragStart,
} from "./types";
import { PROPERTIES, injector, CLASS_NAME } from "./consts";
/**
* Selecto.js is a component that allows you to select elements in the drag area using the mouse or touch.
* @sort 1
* @extends EventEmitter
*/
@Properties(PROPERTIES as any, (prototype, property) => {
const attributes: IObject<any> = {
enumerable: true,
configurable: true,
get() {
return this.options[property];
},
};
const getter = camelize(`get ${property}`);
if (prototype[getter]) {
attributes.get = function() {
return this[getter]();
};
} else {
attributes.get = function() {
return this.options[property];
};
}
const setter = camelize(`set ${property}`);
if (prototype[setter]) {
attributes.set = function(value: any) {
this[setter](value);
};
} else {
attributes.set = function(value: any) {
this.options[property] = value;
};
}
Object.defineProperty(prototype, property, attributes);
})
class Selecto extends EventEmitter<SelectoEvents> {
public options: SelectoOptions;
private target!: ElementType;
private dragContainer!: Element | Window | Element[];
private container!: HTMLElement;
private gesto!: Gesto;
private injectResult!: InjectResult;
private selectedTargets: ElementType[] = [];
private dragScroll: DragScroll = new DragScroll();
private keycon!: KeyController;
private _keydownContinueSelect: boolean;
private _keydownContinueSelectWithoutDeselection: boolean;
/**
*
*/
constructor(options: Partial<SelectoOptions> = {}) {
super();
this.target = options.portalContainer;
let container = options.container;
this.options = {
className: "",
portalContainer: null,
container: null,
dragContainer: null,
selectableTargets: [],
selectByClick: true,
selectFromInside: true,
clickBySelectEnd: false,
hitRate: 100,
continueSelect: false,
continueSelectWithoutDeselect: false,
toggleContinueSelect: null,
toggleContinueSelectWithoutDeselect: null,
keyContainer: null,
scrollOptions: null,
checkInput: false,
preventDefault: false,
boundContainer: false,
preventDragFromInside: true,
dragCondition: null,
rootContainer: null,
checkOverflow: false,
innerScrollOptions: false,
getElementRect: getDefaultElementRect,
cspNonce: "",
ratio: 0,
...options,
};
const portalContainer = this.options.portalContainer;
if (portalContainer) {
container = portalContainer.parentElement;
}
this.container = container || document.body;
this.initElement();
this.initDragScroll();
this.setKeyController();
}
/**
* You can set the currently selected targets.
* selectByClick, continueSelect, and continueSelectWithoutDeselect are not applied.
*/
public setSelectedTargets(
selectedTargets: ElementType[],
): SelectedTargets {
const beforeSelected = this.selectedTargets;
const { added, removed, prevList, list } = diff(
beforeSelected,
selectedTargets
);
this.selectedTargets = selectedTargets;
return {
added: added.map(index => list[index]),
removed: removed.map(index => prevList[index]),
beforeSelected,
selected: selectedTargets,
};
}
/**
* You can set the currently selected targets by points
* selectByClick, continueSelect, and continueSelectWithoutDeselect are not applied.
*/
public setSelectedTargetsByPoints(
point1: number[],
point2: number[],
): SelectedTargetsWithRect {
const left = Math.min(point1[0], point2[0]);
const top = Math.min(point1[1], point2[1]);
const right = Math.max(point1[0], point2[0]);
const bottom = Math.max(point1[1], point2[1]);
const rect: Rect = {
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
};
const data = { ignoreClick: true };
this.findSelectableTargets(data);
const selectedElements = this.hitTest(rect, data, true, null);
const result = this.setSelectedTargets(selectedElements);
return {
...result,
rect,
};
}
/**
* Select target by virtual drag from startPoint to endPoint.
* The target of inputEvent is null.
*/
public selectTargetsByPoints(
startPoint: number[],
endPoint: number[],
) {
const mousedown = new MouseEvent("mousedown", {
clientX: startPoint[0],
clientY: startPoint[1],
cancelable: true,
bubbles: true,
});
const mousemove = new MouseEvent("mousemove", {
clientX: endPoint[0],
clientY: endPoint[1],
cancelable: true,
bubbles: true,
});
const mouseup = new MouseEvent("mousemove", {
clientX: endPoint[0],
clientY: endPoint[1],
cancelable: true,
bubbles: true,
});
const gesto = this.gesto;
const result = gesto.onDragStart(mousedown);
if (result !== false) {
gesto.onDrag(mousemove);
gesto.onDragEnd(mouseup);
}
}
/**
* You can get the currently selected targets.
*/
public getSelectedTargets(): ElementType[] {
return this.selectedTargets;
}
/**
* `OnDragStart` is triggered by an external event.
* @param - external event
* @example
* import Selecto from "selecto";
*
* const selecto = new Selecto();
*
* window.addEventListener("mousedown", e => {
* selecto.triggerDragStart(e);
* });
*/
public triggerDragStart(e: MouseEvent | TouchEvent) {
this.gesto.triggerDragStart(e);
return this;
}
/**
* Destroy elements, properties, and events.
*/
public destroy() {
this.off();
this.keycon && this.keycon.destroy();
this.gesto.unset();
this.injectResult.destroy();
this.dragScroll.dragEnd();
removeEvent(document, "selectstart", this._onDocumentSelectStart);
if (!this.options.portalContainer) {
this.target.parentElement?.removeChild(this.target);
}
this.keycon = null;
this.gesto = null;
this.injectResult = null;
this.target = null;
this.container = null;
this.options = null;
}
public getElementPoints(target: ElementType) {
const getElementRect = this.getElementRect || getDefaultElementRect;
const info = getElementRect(target);
const points = [info.pos1, info.pos2, info.pos4, info.pos3];
if (getElementRect !== getDefaultElementRect) {
const rect = target.getBoundingClientRect();
return fitPoints(points, rect);
}
return points;
}
/**
* Get all elements set in `selectableTargets`.
*/
public getSelectableElements() {
const container = this.container;
const selectableElements: ElementType[] = [];
this.options.selectableTargets.forEach((target) => {
if (isFunction(target)) {
const result = target();
if (result) {
selectableElements.push(...[].slice.call(result));
}
} else if (isNode(target)) {
selectableElements.push(target);
} else if (isObject(target)) {
selectableElements.push(target.value || target.current);
} else {
const elements = [].slice.call(
(getDocument(container)).querySelectorAll(target)
);
selectableElements.push(...elements);
}
});
return selectableElements;
}
/**
* If scroll occurs during dragging, you can manually call this method to check the position again.
*/
public checkScroll() {
if (!this.gesto.isFlag()) {
return;
}
const scrollOptions = this.scrollOptions;
const innerScrollOptions = this.gesto.getEventData().innerScrollOptions;
const hasScrollOptions = innerScrollOptions || scrollOptions?.container;
// If it is a scrolling position, pass drag
if (hasScrollOptions) {
this.dragScroll.checkScroll({
inputEvent: this.gesto.getCurrentEvent(),
...(innerScrollOptions || scrollOptions),
});
}
}
/**
* Find for selectableTargets again during drag event
* You can update selectable targets during an event.
*/
public findSelectableTargets(data: IObject<any> = this.gesto.getEventData()) {
const selectableTargets = this.getSelectableElements();
const selectablePoints = selectableTargets.map(
(target) => this.getElementPoints(target),
);
data.selectableTargets = selectableTargets;
data.selectablePoints = selectablePoints;
data.selectableParentMap = null;
const options = this.options;
const hasIndexesMap = options.checkOverflow || options.innerScrollOptions;
const doc = getDocument(this.container);
if (hasIndexesMap) {
const parentMap = new Map<Element, InnerParentInfo>();
data.selectableInnerScrollParentMap = parentMap;
data.selectableInnerScrollPathsList = selectableTargets.map((target, index) => {
let parentElement = target.parentElement;
let parents: Element[] = [];
const paths: Element[] = [];
while (parentElement && parentElement !== doc.body) {
let info: InnerParentInfo = parentMap.get(parentElement);
if (!info) {
const overflow = getComputedStyle(parentElement).overflow !== "visible";
if (overflow) {
const rect = getDefaultElementRect(parentElement);
info = {
parentElement,
indexes: [],
points: [rect.pos1, rect.pos2, rect.pos4, rect.pos3],
paths: [...paths],
};
parents.push(parentElement);
parents.forEach(prevParentElement => {
parentMap.set(prevParentElement, info);
});
parents = [];
}
}
if (info) {
parentElement = info.parentElement;
parentMap.get(parentElement).indexes.push(index);
paths.push(parentElement);
} else {
parents.push(parentElement);
}
parentElement = parentElement.parentElement;
}
return paths;
});
}
if (!options.checkOverflow) {
data.selectableInners = selectableTargets.map(() => true);
}
this._refreshGroups(data);
return selectableTargets;
}
/**
* External click or mouse events can be applied to the selecto.
* @params - Extenal click or mouse event
* @params - Specify the clicked target directly.
*/
public clickTarget(
e: MouseEvent | TouchEvent,
clickedTarget?: Element
): this {
const { clientX, clientY } = getClient(e);
const dragEvent = {
data: {
selectFlag: false,
},
clientX,
clientY,
inputEvent: e,
isClick: true,
isTrusted: false,
stop: () => {
return false;
},
} as any;
if (this._onDragStart(dragEvent, clickedTarget)) {
this._onDragEnd(dragEvent);
}
return this;
}
private setKeyController() {
const { keyContainer, toggleContinueSelect, toggleContinueSelectWithoutDeselect } = this.options;
if (this.keycon) {
this.keycon.destroy();
this.keycon = null;
}
if (toggleContinueSelect || toggleContinueSelectWithoutDeselect) {
this.keycon = new KeyController(keyContainer || getWindow(this.container));
this.keycon
.keydown(this._onKeyDown)
.keyup(this._onKeyUp)
.on("blur", this._onBlur);
}
}
private setClassName(nextClassName: string) {
this.options.className = nextClassName;
this.target.setAttribute(`class`, `${CLASS_NAME} ${nextClassName || ""}`);
}
private setKeyEvent() {
const { toggleContinueSelect, toggleContinueSelectWithoutDeselect } = this.options;
if ((!toggleContinueSelect && !toggleContinueSelectWithoutDeselect) || this.keycon) {
return;
}
this.setKeyController();
}
// with getter, setter property
private setKeyContainer(keyContainer: HTMLElement | Document | Window) {
const options = this.options;
diffValue(options.keyContainer, keyContainer, () => {
options.keyContainer = keyContainer;
this.setKeyController();
});
}
private getContinueSelect() {
const {
continueSelect,
toggleContinueSelect,
} = this.options;
if (!toggleContinueSelect || !this._keydownContinueSelect) {
return continueSelect;
}
return !continueSelect;
}
private getContinueSelectWithoutDeselect() {
const {
continueSelectWithoutDeselect,
toggleContinueSelectWithoutDeselect,
} = this.options;
if (!toggleContinueSelectWithoutDeselect || !this._keydownContinueSelectWithoutDeselection) {
return continueSelectWithoutDeselect;
}
return !continueSelectWithoutDeselect;
}
private setToggleContinueSelect(
toggleContinueSelect: string[][] | string[] | string
) {
const options = this.options;
diffValue(options.toggleContinueSelect, toggleContinueSelect, () => {
options.toggleContinueSelect = toggleContinueSelect;
this.setKeyEvent();
});
}
private setToggleContinueSelectWithoutDeselect(
toggleContinueSelectWithoutDeselect: string[][] | string[] | string
) {
const options = this.options;
diffValue(options.toggleContinueSelectWithoutDeselect, toggleContinueSelectWithoutDeselect, () => {
options.toggleContinueSelectWithoutDeselect = toggleContinueSelectWithoutDeselect;
this.setKeyEvent();
});
}
private setPreventDefault(value: boolean) {
this.gesto.options.preventDefault = value;
}
private setCheckInput(value: boolean) {
this.gesto.options.checkInput = value;
}
private initElement() {
const {
dragContainer,
checkInput,
preventDefault,
preventClickEventOnDragStart,
preventClickEventOnDrag,
preventClickEventByCondition,
preventRightClick = true,
className,
} = this.options;
const container = this.container;
this.target = createElement(
(<div className={`${CLASS_NAME} ${className || ""}`}></div>) as any,
this.target,
container,
);
const target = this.target;
this.dragContainer =
typeof dragContainer === "string"
? [].slice.call(getDocument(container).querySelectorAll(dragContainer))
: dragContainer || (this.target.parentNode as any);
this.gesto = new Gesto(this.dragContainer, {
checkWindowBlur: true,
container: getWindow(container),
checkInput,
preventDefault,
preventClickEventOnDragStart,
preventClickEventOnDrag,
preventClickEventByCondition,
preventRightClick,
}).on({
dragStart: this._onDragStart,
drag: this._onDrag,
dragEnd: this._onDragEnd,
});
addEvent(document, "selectstart", this._onDocumentSelectStart);
this.injectResult = injector.inject(target, {
nonce: this.options.cspNonce,
});
}
private hitTest(
selectRect: Rect,
data: any,
isDrag: boolean,
gestoEvent: any,
) {
const { hitRate, selectByClick } = this.options;
const { left, top, right, bottom } = selectRect;
const innerGroups: Record<string | number, Record<string | number, number[]>> = data.innerGroups;
const innerWidth = data.innerWidth;
const innerHeight = data.innerHeight;
const clientX = gestoEvent?.clientX;
const clientY = gestoEvent?.clientY;
const ignoreClick = data.ignoreClick;
const rectPoints = [
[left, top],
[right, top],
[right, bottom],
[left, bottom],
];
const isHit = (points: number[][], el: Element) => {
const hitRateValue =
typeof hitRate === "function"
? splitUnit(`${hitRate(el)}`)
: splitUnit(`${hitRate}`);
const inArea = ignoreClick
? false
: isInside([clientX, clientY], points);
if (!isDrag && selectByClick && inArea) {
return true;
}
const overlapPoints = getOverlapPoints(rectPoints, points);
if (!overlapPoints.length) {
return false;
}
let overlapSize = getAreaSize(overlapPoints);
// Line
let targetSize = 0;
if (overlapSize === 0 && getAreaSize(points) === 0) {
targetSize = getLineSize(points);
overlapSize = getLineSize(overlapPoints);
} else {
targetSize = getAreaSize(points);
}
if (hitRateValue.unit === "px") {
return overlapSize >= hitRateValue.value;
} else {
const rate = between(
Math.round((overlapSize / targetSize) * 100),
0,
100
);
return rate >= Math.min(100, hitRateValue.value);
}
};
const selectableTargets: ElementType[] = data.selectableTargets;
const selectablePoints: number[][][] = data.selectablePoints;
const selectableInners: boolean[] = data.selectableInners;
if (!innerGroups) {
return selectableTargets.filter((_, i) => {
if (!selectableInners[i]) {
return false;
}
return isHit(selectablePoints[i], selectableTargets[i]);
});
}
const selectedTargets: ElementType[] = [];
const minX = Math.floor(left / innerWidth);
const maxX = Math.floor(right / innerWidth);
const minY = Math.floor(top / innerHeight);
const maxY = Math.floor(bottom / innerHeight);
for (let x = minX; x <= maxX; ++x) {
const yGroups = innerGroups[x];
if (!yGroups) {
continue;
}
for (let y = minY; y <= maxY; ++y) {
const group = yGroups[y];
if (!group) {
continue;
}
group.forEach(index => {
const points = selectablePoints[index];
const inner = selectableInners[index];
const target = selectableTargets[index];
if (inner && isHit(points, target)) {
selectedTargets.push(target);
}
});
}
}
return filterDuplicated(selectedTargets);
}
private initDragScroll() {
this.dragScroll
.on("scrollDrag", ({ next }) => {
next(this.gesto.getCurrentEvent());
})
.on("scroll", ({ container, direction }) => {
const innerScrollOptions = this.gesto.getEventData().innerScrollOptions;
if (innerScrollOptions) {
this.emit("innerScroll", {
container,
direction,
});
} else {
this.emit("scroll", {
container,
direction,
});
}
})
.on("move", ({ offsetX, offsetY, inputEvent }) => {
const gesto = this.gesto;
if (!gesto || !gesto.isFlag()) {
return;
}
const data = this.gesto.getEventData();
const boundArea = data.boundArea;
data.startX -= offsetX;
data.startY -= offsetY;
const innerScrollOptions = this.gesto.getEventData().innerScrollOptions;
const container = innerScrollOptions?.container;
let isMoveInnerScroll = false;
if (container) {
const parentMap: Map<Element, InnerParentInfo> = data.selectableInnerScrollParentMap;
const parentInfo = parentMap.get(container);
if (parentInfo) {
parentInfo.paths.forEach(scrollContainer => {
const containerInfo = parentMap.get(scrollContainer);
containerInfo.points.forEach(pos => {
pos[0] -= offsetX;
pos[1] -= offsetY;
});
});
parentInfo.indexes.forEach(index => {
data.selectablePoints[index].forEach((pos) => {
pos[0] -= offsetX;
pos[1] -= offsetY;
});
});
isMoveInnerScroll = true;
}
}
if (!isMoveInnerScroll) {
data.selectablePoints.forEach((points: number[][]) => {
points.forEach((pos) => {
pos[0] -= offsetX;
pos[1] -= offsetY;
});
});
}
this._refreshGroups(data);
boundArea.left -= offsetX;
boundArea.right -= offsetX;
boundArea.top -= offsetY;
boundArea.bottom -= offsetY;
this.gesto.scrollBy(
offsetX,
offsetY,
inputEvent.inputEvent,
// false
);
this._checkSelected(this.gesto.getCurrentEvent());
});
}
private _select(
selectedTargets: ElementType[],
rect: Rect,
e: OnDragEvent,
isStart?: boolean,
isDragStartEnd = false,
) {
const inputEvent = e.inputEvent;
const data = e.data;
const result = this.setSelectedTargets(selectedTargets);
const { added, removed, prevList, list } = diff(
data.startSelectedTargets,
selectedTargets,
);
const startResult = {
startSelected: prevList,
startAdded: added.map(i => list[i]),
startRemoved: removed.map(i => prevList[i]),
};
if (isStart) {
/**
* When the select(drag) starts, the selectStart event is called.
* @memberof Selecto
* @event selectStart
* @param {Selecto.OnSelect} - Parameters for the selectStart event
* @example
* import Selecto from "selecto";
*
* const selecto = new Selecto({
* container: document.body,
* selectByClick: true,
* selectFromInside: false,
* });
*
* selecto.on("selectStart", e => {
* e.added.forEach(el => {
* el.classList.add("selected");
* });
* e.removed.forEach(el => {
* el.classList.remove("selected");
* });
* }).on("selectEnd", e => {
* e.afterAdded.forEach(el => {
* el.classList.add("selected");
* });
* e.afterRemoved.forEach(el => {
* el.classList.remove("selected");
* });
* });
*/
this.emit("selectStart", {
...result,
...startResult,
rect,
inputEvent,
data: data.data,
isTrusted: e.isTrusted,
isDragStartEnd,
});
}
if (result.added.length || result.removed.length) {
/**
* When the select in real time, the select event is called.
* @memberof Selecto
* @event select
* @param {Selecto.OnSelect} - Parameters for the select event
* @example
* import Selecto from "selecto";
*
* const selecto = new Selecto({
* container: document.body,
* selectByClick: true,
* selectFromInside: false,
* });
*
* selecto.on("select", e => {
* e.added.forEach(el => {
* el.classList.add("selected");
* });
* e.removed.forEach(el => {
* el.classList.remove("selected");
* });
* });
*/
this.emit("select", {
...result,
...startResult,
rect,
inputEvent,
data: data.data,
isTrusted: e.isTrusted,
isDragStartEnd,
});
}
}
private _selectEnd(
startSelectedTargets: ElementType[],
startPassedTargets: ElementType[],
rect: Rect,
e: OnDragEvent,
isDragStartEnd: boolean = false,
) {
const { inputEvent, isDouble, data } = e;
const type = inputEvent && inputEvent.type;
const isDragStart = type === "mousedown" || type === "touchstart";
const { added, removed, prevList, list } = diff(
startSelectedTargets,
this.selectedTargets
);
const {
added: afterAdded,
removed: afterRemoved,
prevList: afterPrevList,
list: afterList,
} = diff(startPassedTargets, this.selectedTargets);
/**
* When the select(dragEnd or click) ends, the selectEnd event is called.
* @memberof Selecto
* @event selectEnd
* @param {Selecto.OnSelectEnd} - Parameters for the selectEnd event
* @example
* import Selecto from "selecto";
*
* const selecto = new Selecto({
* container: document.body,
* selectByClick: true,
* selectFromInside: false,
* });
*
* selecto.on("selectStart", e => {
* e.added.forEach(el => {
* el.classList.add("selected");
* });
* e.removed.forEach(el => {
* el.classList.remove("selected");
* });
* }).on("selectEnd", e => {
* e.afterAdded.forEach(el => {
* el.classList.add("selected");
* });
* e.afterRemoved.forEach(el => {
* el.classList.remove("selected");
* });
* });
*/
this.emit("selectEnd", {
startSelected: startSelectedTargets,
beforeSelected: startPassedTargets,
selected: this.selectedTargets,
added: added.map((index) => list[index]),
removed: removed.map((index) => prevList[index]),
afterAdded: afterAdded.map((index) => afterList[index]),
afterRemoved: afterRemoved.map((index) => afterPrevList[index]),
isDragStart: isDragStart && isDragStartEnd,
isDragStartEnd: isDragStart && isDragStartEnd,
isClick: !!e.isClick,
isDouble: !!isDouble,
rect,
inputEvent,
data: data.data,
isTrusted: e.isTrusted,
});
}
private _onDragStart = (e: OnDragStart<Gesto>, clickedTarget?: Element) => {
const { data, clientX, clientY, inputEvent } = e;
const {
selectFromInside,
selectByClick,
rootContainer,
boundContainer,
preventDragFromInside = true,
clickBySelectEnd,
dragCondition,
} = this.options;
if (dragCondition && !dragCondition(e)) {
e.stop();
return;
}
data.data = {};
const win = getWindow(this.container);
data.innerWidth = win.innerWidth;
data.innerHeight = win.innerHeight;
this.findSelectableTargets(data);
data.startSelectedTargets = this.selectedTargets;
data.scaleMatrix = createMatrix();
data.containerX = 0;
data.containerY = 0;
const container = this.container;
let boundArea = {
left: -Infinity,
top: -Infinity,
right: Infinity,
bottom: Infinity,
};
if (rootContainer) {
const containerRect = this.container.getBoundingClientRect();
data.containerX = containerRect.left;
data.containerY = containerRect.top;
data.scaleMatrix = getDistElementMatrix(this.container, rootContainer);
}
if (boundContainer) {
const boundInfo: Required<BoundContainer> =
isObject(boundContainer) && "element" in boundContainer
? {
left: true,
top: true,
bottom: true,
right: true,
...boundContainer,
}
: {
element: boundContainer,
left: true,
top: true,
bottom: true,
right: true,
};
const boundElement = boundInfo.element;
let rectElement: HTMLElement;
if (boundElement) {
if (isString(boundElement)) {
rectElement = getDocument(container).querySelector(boundElement);
} else if (boundElement === true) {
rectElement = this.container;
} else {
rectElement = boundElement;
}
const rect = rectElement.getBoundingClientRect();
if (boundInfo.left) {
boundArea.left = rect.left;
}
if (boundInfo.top) {
boundArea.top = rect.top;
}
if (boundInfo.right) {
boundArea.right = rect.right;
}
if (boundInfo.bottom) {
boundArea.bottom = rect.bottom;
}
}
}
data.boundArea = boundArea;
const hitRect = {
left: clientX,
top: clientY,
right: clientX,
bottom: clientY,
width: 0,
height: 0,
};
let firstPassedTargets: ElementType[] = [];
// allow click on select
const allowClickBySelectEnd = selectByClick && !clickBySelectEnd;
let hasInsideTargets = false;
if (!selectFromInside || allowClickBySelectEnd) {
const pointTarget = this._findElement(
clickedTarget || inputEvent.target, // elementFromPoint(clientX, clientY),
data.selectableTargets,
);
hasInsideTargets = !!pointTarget;
if (allowClickBySelectEnd) {
firstPassedTargets = pointTarget ? [pointTarget] : [];
}
}
const isPreventSelect = !selectFromInside && hasInsideTargets;
// prevent drag from inside when selectByClick is false
if (isPreventSelect && !selectByClick) {
e.stop();
return false;
}
const type = inputEvent.type;
const isTrusted = type === "mousedown" || type === "touchstart";
/**
* When the drag starts (triggers on mousedown or touchstart), the dragStart event is called.
* Call the stop () function if you have a specific element or don't want to raise a select
* @memberof Selecto
* @event dragStart
* @param {OnDragStart} - Parameters for the dragStart event
* @example
* import Selecto from "selecto";
*
* const selecto = new Selecto({
* container: document.body,
* selectByClick: true,
* selectFromInside: false,
* });
*
* selecto.on("dragStart", e => {
* if (e.inputEvent.target.tagName === "SPAN") {
* e.stop();
* }
* }).on("select", e => {
* e.added.forEach(el => {
* el.classList.add("selected");
* });
* e.removed.forEach(el => {
* el.classList.remove("selected");
* });
* });
*/
const result =
!(e).isClick && isTrusted
? this.emit("dragStart", { ...e, data: data.data })
: true;
if (!result) {
e.stop();
return false;
}
if (this.continueSelect) {
firstPassedTargets = passTargets(
this.selectedTargets,
firstPassedTargets,
this.continueSelectWithoutDeselect,
);
data.startPassedTargets = this.selectedTargets;
} else {
data.startPassedTargets = [];
}
this._select(
firstPassedTargets,
hitRect,
e,
true,
isPreventSelect && selectByClick && !clickBySelectEnd && preventDragFromInside,
);
data.startX = clientX;
data.startY = clientY;
data.selectFlag = false;
data.preventDragFromInside = false;
if (inputEvent.target) {
const offsetPos = calculateMatrixDist(data.scaleMatrix, [
clientX - data.containerX,
clientY - data.containerY,
]);
this.target.style.cssText += `position: ${rootContainer ? "absolute" : "fixed"};`
+ `left:0px;top:0px;`
+ `transform: translate(${offsetPos[0]}px, ${offsetPos[1]}px)`;
}
if (isPreventSelect && selectByClick && !clickBySelectEnd) {
inputEvent.preventDefault();
// prevent drag from inside when selectByClick is true and force call `selectEnd`
if (preventDragFromInside) {
this._selectEnd(
data.startSelectedTargets,
data.startPassedTargets,
hitRect,
e,
true,
);
data.preventDragFromInside = true;
}
} else {
data.selectFlag = true;
// why?
// if (type === "touchstart") {
// inputEvent.preventDefault();
// }
const { scrollOptions, innerScrollOptions } = this.options;
let isInnerScroll = false
if (innerScrollOptions) {
const inputEvent = e.inputEvent;
const target = inputEvent.target;
let innerScrollElement: HTMLElement | null = null;
let parentElement = target;
while (parentElement && parentElement !== getDocument(container).body) {
const overflow = getComputedStyle(parentElement).overflow !== "visible";
if (overflow) {
innerScrollElement = parentElement;
break;
}
parentElement = parentElement.parentElement;
}
if (innerScrollElement) {
data.innerScrollOptions = {
container: innerScrollElement,
checkScrollEvent: true,
...(innerScrollOptions === true ? {} : innerScrollOptions),
};
this.dragScroll.dragStart(e, data.innerScrollOptions);
isInnerScroll = true;
}
}
if (!isInnerScroll && scrollOptions && scrollOptions.container) {
this.dragScroll.dragStart(e, scrollOptions);
}
if (isPreventSelect && selectByClick && clickBySelectEnd) {
data.selectFlag = false;
e.preventDrag();
}
}
return true;
};
private _checkSelected(e: any, rect = getRect(e, this.options.ratio)) {
const { data } = e;
const { top, left, width, height } = rect;
const selectFlag = data.selectFlag;
const {
containerX,
containerY,
scaleMatrix,
} = data;
const offsetPos = calculateMatrixDist(scaleMatrix, [
left - containerX,
top - containerY,
]);
const offsetSize = calculateMatrixDist(scaleMatrix, [
width,
height,
]);
let selectedTargets: ElementType[] = [];
if (selectFlag) {
this.target.style.cssText +=
`display: block;` +
`left:0px;top:0px;` +
`transform: translate(${offsetPos[0]}px, ${offsetPos[1]}px);` +
`width:${offsetSize[0]}px;height:${offsetSize[1]}px;`;
const passedTargets = this.hitTest(
rect,
data,
true,
e,
);
selectedTargets = passTargets(
data.startPassedTargets,
passedTargets,
this.continueSelect && this.continueSelectWithoutDeselect,
);
}
/**
* When the drag, the drag event is called.
* Call the stop () function if you have a specific element or don't want to raise a select
* @memberof Selecto
* @event drag
* @param {OnDrag} - Parameters for the drag event
* @example
* import Selecto from "selecto";
*
* const selecto = new Selecto({
* container: document.body,
* selectByClick: true,
* selectFromInside: false,
* });
*
* selecto.on("drag", e => {
* e.stop();
* }).on("select", e => {
* e.added.forEach(el => {
* el.classList.add("selected");
* });
* e.removed.forEach(el => {
* el.classList.remove("selected");
* });
* });
*/
const result = this.emit("drag", {
...e,
data: data.data,
isSelect: selectFlag,
rect,
});
if (result === false) {
this.target.style.cssText += "display: none;";
e.stop();
return;
}
if (selectFlag) {
this._select(selectedTargets, rect, e);
}
}
private _onDrag = (e: OnDrag) => {
if (e.data.selectFlag) {
const scrollOptions = this.scrollOptions;
const innerScrollOptions = e.data.innerScrollOptions;
const hasScrollOptions = innerScrollOptions || scrollOptions?.container;
// If it is a scrolling position, pass drag
if (hasScrollOptions && !e.isScroll && this.dragScroll.drag(e, innerScrollOptions || scrollOptions)) {
return;
}
}
this._checkSelected(e);
};
private _onDragEnd = (e: OnDragEvent) => {
const { data, inputEvent } = e;
const rect = getRect(e, this.options.ratio);
const selectFlag = data.selectFlag;
const container = this.container;
/**
* When the drag ends (triggers on mouseup or touchend after drag), the dragEnd event is called.
* @memberof Selecto
* @event dragEnd
* @param {OnDragEnd} - Parameters for the dragEnd event
*/
if (inputEvent) {
this.emit("dragEnd", {
isDouble: !!e.isDouble,
isClick: !!e.isClick,
isDrag: false,
isSelect: selectFlag,
...e,
data: data.data,
rect,
});
}
this.target.style.cssText += "display: none;";
if (selectFlag) {
data.selectFlag = false;
this.dragScroll.dragEnd();
} else if (this.selectByClick && this.clickBySelectEnd) {
// only clickBySelectEnd
const pointTarget = this._findElement(
inputEvent?.target || elementFromPoint(container, e.clientX, e.clientY),
data.selectableTargets,
);
this._select(pointTarget ? [pointTarget] : [], rect, e);
}
if (!data.preventDragFromInside) {
this._selectEnd(
data.startSelectedTargets,
data.startPassedTargets,
rect,
e
);
}
};
private _sameCombiKey(e: any, keys: string | string[] | string[][], isKeyup?: boolean) {
if (!keys) {
return false;
}
const combi = getCombi(e.inputEvent, e.key);
const nextKeys = [].concat(keys);
const toggleKeys = isArray(nextKeys[0]) ? nextKeys : [nextKeys];
if (isKeyup) {
const singleKey = e.key;
return toggleKeys.some((keys) =>
keys.some((key: string) => key === singleKey)
);
}
return toggleKeys.some((keys) =>
keys.every((key: string) => combi.indexOf(key) > -1)
);
}
private _onKeyDown = (e: KeyControllerEvent) => {
const options = this.options;
let isKeyDown = false;
if (!this._keydownContinueSelect) {
const result = this._sameCombiKey(e, options.toggleContinueSelect);
this._keydownContinueSelect = result;
isKeyDown ||= result;
}
if (!this._keydownContinueSelectWithoutDeselection) {
const result = this._sameCombiKey(e, options.toggleContinueSelectWithoutDeselect);
this._keydownContinueSelectWithoutDeselection = result;
isKeyDown ||= result;
}
if (!isKeyDown) {
return;
}
/**
* When you keydown the key you specified in toggleContinueSelect, the keydown event is called.
* @memberof Selecto
* @event keydown
* @example
* import Selecto from "selecto";
*
* const selecto = new Selecto({
* container: document.body,
* toggleContinueSelect: "shift";
* keyContainer: window,
* });
*
* selecto.on("keydown", () => {
* document.querySelector(".button").classList.add("selected");
* }).on("keyup", () => {
* document.querySelector(".button").classList.remove("selected");
* }).on("select", e => {
* e.added.forEach(el => {
* el.classList.add("selected");
* });
* e.removed.forEach(el => {
* el.classList.remove("selected");
* });
* });
*/
this.emit("keydown", {
keydownContinueSelect: this._keydownContinueSelect,
keydownContinueSelectWithoutDeselection: this._keydownContinueSelectWithoutDeselection,
});
};
private _onKeyUp = (e: KeyControllerEvent) => {
const options = this.options;
let isKeyUp = false;
if (this._keydownContinueSelect) {
const result = this._sameCombiKey(e, options.toggleContinueSelect, true);
this._keydownContinueSelect = !result;
isKeyUp ||= result;
}
if (this._keydownContinueSelectWithoutDeselection) {
const result = this._sameCombiKey(e, options.toggleContinueSelectWithoutDeselect, true);
this._keydownContinueSelectWithoutDeselection = !result;
isKeyUp ||= result;
}
if (!isKeyUp) {
return;
}
/**
* When you keyup the key you specified in toggleContinueSelect, the keyup event is called.
* @memberof Selecto
* @event keyup
* @example
* import Selecto from "selecto";
*
* const selecto = new Selecto({
* container: document.body,
* toggleContinueSelect: "shift";
* keyContainer: window,
* });
*
* selecto.on("keydown", () => {
* document.querySelector(".button").classList.add("selected");
* }).on("keyup", () => {
* document.querySelector(".button").classList.remove("selected");
* }).on("select", e => {
* e.added.forEach(el => {
* el.classList.add("selected");
* });
* e.removed.forEach(el => {
* el.classList.remove("selected");
* });
* });
*/
this.emit("keyup", {
keydownContinueSelect: this._keydownContinueSelect,
keydownContinueSelectWithoutDeselection: this._keydownContinueSelectWithoutDeselection,
});
};
private _onBlur = () => {
if (this._keydownContinueSelect || this._keydownContinueSelectWithoutDeselection) {
this._keydownContinueSelect = false;
this._keydownContinueSelectWithoutDeselection = false;
this.emit("keyup", {
keydownContinueSelect: this._keydownContinueSelect,
keydownContinueSelectWithoutDeselection: this._keydownContinueSelectWithoutDeselection,
});
}
};
private _onDocumentSelectStart = (e: any) => {
const doc = getDocument(this.container);
if (!this.gesto.isFlag()) {
return;
}
let dragContainer = this.dragContainer;
if (dragContainer === getWindow(this.container)) {
dragContainer = doc.documentElement;
}
const containers = isNode(dragContainer)
? [dragContainer]
: ([].slice.call(dragContainer) as Element[]);
const target = e.target;
containers.some((container) => {
if (container === target || container.contains(target)) {
e.preventDefault();
return true;
}
});
};
private _findElement(clickedTarget: ElementType, selectableTargets: Element[]) {
let pointTarget = clickedTarget;
while (pointTarget) {
if (selectableTargets.indexOf(pointTarget) > -1) {
break;
}
pointTarget = pointTarget.parentElement;
}
return pointTarget;
}
private _refreshGroups(data: IObject<any>) {
const innerWidth = data.innerWidth;
const innerHeight = data.innerHeight;
const selectablePoints: number[][][] = data.selectablePoints;
if (this.options.checkOverflow) {
const innerScrollContainer = this.gesto.getEventData().innerScrollOptions?.container;
const parentMap: Map<Element, InnerParentInfo> = data.selectableInnerScrollParentMap;
const innerScrollPathsList: Element[][] = data.selectableInnerScrollPathsList;
data.selectableInners = innerScrollPathsList.map((innerScrollPaths, i) => {
let isAlwaysTrue = false;
return innerScrollPaths.every(target => {
if (isAlwaysTrue) {
return true;
}
if (target === innerScrollContainer) {
isAlwaysTrue = true;
return true;
}
const rect = parentMap.get(target);
if (rect) {
const points1 = selectablePoints[i];
const points2 = rect.points;
const overlapPoints = getOverlapPoints(points1, points2);
if (!overlapPoints.length) {
return false;
}
}
return true;
});
});
}
if (!innerWidth || !innerHeight) {
data.innerGroups = null;
} else {
const selectablePoints: number[][][] = data.selectablePoints;
const groups: Record<string | number, Record<string | number, number[]>> = {};
selectablePoints.forEach((points, i) => {
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
points.forEach(pos => {
const x = Math.floor(pos[0] / innerWidth);
const y = Math.floor(pos[1] / innerHeight);
minX = Math.min(x, minX);
maxX = Math.max(x, maxX);
minY = Math.min(y, minY);
maxY = Math.max(y, maxY);
});
for (let x = minX; x <= maxX; ++x) {
for (let y = minY; y <= maxY; ++y) {
groups[x] = groups[x] || {};
groups[x][y] = groups[x][y] || [];
groups[x][y].push(i);
}
}
});
data.innerGroups = groups;
}
}
}
interface Selecto extends SelectoProperties { }
export default Selecto;