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 = { 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 { 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 = {}) { 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 = 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(); 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( (
) 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> = 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 = 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, 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 = 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) { 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 = 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> = {}; 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;