diff --git a/.github/workflows/run-e2e.yml b/.github/workflows/run-e2e.yml new file mode 100644 index 00000000..d11c7211 --- /dev/null +++ b/.github/workflows/run-e2e.yml @@ -0,0 +1,21 @@ +name: Run tests +on: [push, pull_request] +jobs: + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2.1.5 + with: + node-version: 14.15.4 + - name: install + run: npm install + working-directory: ./packages/axes + - name: test + run: npm run coverage + working-directory: ./packages/axes + - name: Coveralls GitHub Action + uses: coverallsapp/github-action@v1.1.2 + with: + path-to-lcov: ./packages/axes/coverage/lcov.info + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/lerna.json b/lerna.json index 19cfa8cc..823a83bd 100644 --- a/lerna.json +++ b/lerna.json @@ -10,7 +10,7 @@ { "basePath": "packages/axes/dist", "dists": [ - "demo/dist" + "packages/demo/dist" ] } ], diff --git a/package.json b/package.json index 10ededef..2fe9a50f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "description": "A module used to change the information of user action entered by various input devices such as touch screen or mouse into the logical virtual coordinates. You can easily create a UI that responds to user actions.", "private": true, "scripts": { - "lint": "eslint ./packages/axes/src/**/*.ts", + "lint": "eslint ./packages/axes/src/**/*.ts", "packages": "npm run packages:update && npm run packages:build && npm run packages:publish", "packages:update": "lerna-helper version", "packages:build": "lerna run build --ignore demo", @@ -11,8 +11,8 @@ "docs:build": "rm -rf ./packages/demo/docs/api && jsdoc-to-mdx -c ./jsdoc-to-mdx.json", "demo:build": "npm run docs:build && npm run build --prefix packages/demo", "demo:build-docusaurus": "npm run build --prefix demo", - "demo:deploy": "lerna-helper deploy --base @egjs/axes --remote upstream", - "demo:deploy-origin": "lerna-helper deploy --base @egjs/axes --remote origin", + "demo:deploy": "lerna-helper deploy --base @egjs/axes --src packages/demo/build/ --remote upstream", + "demo:deploy-origin": "lerna-helper deploy --base @egjs/axes --src packages/demo/build/ --remote origin", "release": "lerna-helper release --base @egjs/axes --remote upstream --branch master", "prepush": "npm run lint", "commitmsg": "node config/validate-commit-msg.js" @@ -33,16 +33,16 @@ "jsdoc-to-mdx": "^1.1.2", "lerna": "^5.1.4", "typescript": "^4.6.2", - "eslint": "^7.32.0", - "eslint-config-prettier": "^8.3.0", - "eslint-plugin-import": "^2.25.4", - "eslint-plugin-jsdoc": "^37.8.0", - "eslint-plugin-prefer-arrow": "^1.2.3", - "eslint-plugin-prettier": "^4.0.0", - "eslint-plugin-react": "^7.22.0", - "@typescript-eslint/eslint-plugin": "^5.11.0", - "@typescript-eslint/eslint-plugin-tslint": "^5.11.0", - "@typescript-eslint/parser": "^5.11.0" + "eslint": "^7.32.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-import": "^2.25.4", + "eslint-plugin-jsdoc": "^37.8.0", + "eslint-plugin-prefer-arrow": "^1.2.3", + "eslint-plugin-prettier": "^4.0.0", + "eslint-plugin-react": "^7.22.0", + "@typescript-eslint/eslint-plugin": "^5.11.0", + "@typescript-eslint/eslint-plugin-tslint": "^5.11.0", + "@typescript-eslint/parser": "^5.11.0" }, "workspaces": { "packages": [ diff --git a/packages/axes/src/InputObserver.ts b/packages/axes/src/InputObserver.ts index edf8f2b6..d4724ea5 100644 --- a/packages/axes/src/InputObserver.ts +++ b/packages/axes/src/InputObserver.ts @@ -68,7 +68,13 @@ export class InputObserver implements InputTypeObserver { this._moveDistance = this._axisManager.get(input.axes); } - public change(input: InputType, event, offset: Axis, useAnimation?: boolean) { + public change( + input: InputType, + event, + offset: Axis, + useAnimation?: boolean, + velocity?: number[] + ) { if ( this._isStopped || !this._interruptManager.isInterrupting() || @@ -80,7 +86,10 @@ export class InputObserver implements InputTypeObserver { if (nativeEvent.__childrenAxesAlreadyChanged) { return; } - let depaPos: Axis = this._moveDistance || this._axisManager.get(input.axes); + let depaPos: Axis = + !this._moveDistance || velocity + ? this._axisManager.get(input.axes) + : this._moveDistance; let destPos: Axis; // for outside logic @@ -113,8 +122,7 @@ export class InputObserver implements InputTypeObserver { event, }; if (useAnimation) { - const duration = this._animationManager.getDuration(destPos, depaPos); - this._animationManager.animateTo(destPos, duration, changeOption); + this._animationManager.changeTo(destPos, changeOption); } else { const isCanceled = !this._eventManager.triggerChange( destPos, @@ -126,6 +134,13 @@ export class InputObserver implements InputTypeObserver { this._isStopped = true; this._moveDistance = null; this._animationManager.finish(false); + } else if (velocity) { + const displacement = this._animationManager.getDisplacement(velocity); + const nextOffset = toAxis(input.axes, displacement); + destPos = this._atOutside( + map(destPos, (v, k) => v + (nextOffset[k] || 0)) + ); + this._animationManager.changeTo(destPos, changeOption); } } } @@ -181,6 +196,7 @@ export class InputObserver implements InputTypeObserver { destPos, duration, delta: this._axisManager.getDelta(depaPos, destPos), + triggerAnimationEvent: true, inputEvent: event, input, isTrusted: true, diff --git a/packages/axes/src/animation/AnimationManager.ts b/packages/axes/src/animation/AnimationManager.ts index 90024d2b..77fba493 100644 --- a/packages/axes/src/animation/AnimationManager.ts +++ b/packages/axes/src/animation/AnimationManager.ts @@ -111,12 +111,10 @@ export abstract class AnimationManager { if (!every(pos, (v, k) => orgPos[k] === v)) { this.eventManager.triggerChange(pos, orgPos, option, !!option); } - this._animateParam = null; - if (this._raf) { - cancelAnimationFrame(this._raf); + if (this._animateParam.triggerAnimationEvent) { + this.eventManager.triggerAnimationEnd(!!option?.event); } - this._raf = null; - this.eventManager.triggerAnimationEnd(!!option?.event); + this._removeAnimationParam(); } } @@ -189,6 +187,27 @@ export abstract class AnimationManager { return userWish; } + public changeTo( + destPos: Axis, + option: ChangeEventOption + ): void { + const depaPos = this.axisManager.get(option.input.axes); + if (this._raf) { + cancelAnimationFrame(this._raf); + } + + if (!equal(destPos, depaPos)) { + const newParam = { + depaPos, + destPos: destPos, + duration: this.getDuration(destPos, depaPos), + delta: this.axisManager.getDelta(depaPos, destPos), + triggerAnimationEvent: false, + }; + this._animateLoop(newParam, () => this._removeAnimationParam()); + } + } + public animateTo( destPos: Axis, duration: number, @@ -226,6 +245,7 @@ export abstract class AnimationManager { destPos: userWish.destPos, duration: userWish.duration, delta: this.axisManager.getDelta(depaPos, userWish.destPos), + triggerAnimationEvent: true, isTrusted: !!inputEvent, inputEvent, input: option?.input || null, @@ -297,6 +317,7 @@ export abstract class AnimationManager { this._options.maximumDuration ), delta: this.axisManager.getDelta(depaPos, destPos), + triggerAnimationEvent: true, inputEvent, input: option?.input || null, isTrusted: !!inputEvent, @@ -304,6 +325,14 @@ export abstract class AnimationManager { }; } + private _removeAnimationParam() { + this._animateParam = null; + if (this._raf) { + cancelAnimationFrame(this._raf); + } + this._raf = null; + } + private _animateLoop(param: AnimationParam, complete: () => void): void { if (param.duration) { this._animateParam = { diff --git a/packages/axes/src/inputType/InputType.ts b/packages/axes/src/inputType/InputType.ts index 2c79a32d..73f7117d 100644 --- a/packages/axes/src/inputType/InputType.ts +++ b/packages/axes/src/inputType/InputType.ts @@ -29,7 +29,7 @@ export interface InputType { export interface InputTypeObserver { options: AxesOption; get(inputType: InputType): Axis; - change(inputType: InputType, event, offset: Axis, useAnimation?: boolean); + change(inputType: InputType, event, offset: Axis, useAnimation?: boolean, velocity?: number[]); hold(inputType: InputType, event); release( inputType: InputType, diff --git a/packages/axes/src/inputType/PanInput.ts b/packages/axes/src/inputType/PanInput.ts index 29c39812..209fdfa0 100644 --- a/packages/axes/src/inputType/PanInput.ts +++ b/packages/axes/src/inputType/PanInput.ts @@ -21,7 +21,12 @@ import { MOUSE_LEFT, ANY, } from "../const"; -import { ActiveEvent, ElementType, InputEventType } from "../types"; +import { + ActiveEvent, + ElementType, + ExtendedEvent, + InputEventType, +} from "../types"; import { convertInputType, @@ -40,6 +45,7 @@ export interface PanInputOption { preventClickOnDrag?: boolean; iOSEdgeSwipeThreshold?: number; releaseOnScroll?: boolean; + useAcceleration?: boolean; touchAction?: string; } @@ -93,8 +99,9 @@ export const getDirectionByAngle = ( * @param {Number} [scale[1]=1] vertical axis scale 수직축 배율 * @param {Number} [thresholdAngle=45] The threshold value that determines whether user action is horizontal or vertical (0~90) 사용자의 동작이 가로 방향인지 세로 방향인지 판단하는 기준 각도(0~90) * @param {Number} [threshold=0] Minimal pan distance required before recognizing 사용자의 Pan 동작을 인식하기 위해산 최소한의 거리 - * @param {Boolean} [preventClickOnDrag=false] Whether to cancel the {@link https://developer.mozilla.org/en/docs/Web/API/Element/click_event click} event when the user finishes dragging more than 1 pixel 사용자가 1픽셀 이상 드래그를 마쳤을 때 {@link https://developer.mozilla.org/ko/docs/Web/API/Element/click_event click} 이벤트 취소 여부 + * @param {Boolean} [preventClickOnDrag=false] Whether to cancel the {@link https://developer.mozilla.org/en/docs/Web/API/Element/click_event click} event when the user finishes dragging more than threshold 사용자가 threshold 이상 드래그를 마쳤을 때 {@link https://developer.mozilla.org/ko/docs/Web/API/Element/click_event click} 이벤트 취소 여부 * @param {Number} [iOSEdgeSwipeThreshold=30] Area (px) that can go to the next page when swiping the right edge in iOS safari iOS Safari에서 오른쪽 엣지를 스와이프 하는 경우 다음 페이지로 넘어갈 수 있는 영역(px) + * @param {Boolean} [useAcceleration=false] Whether to apply the dragging speed to coordinate changes. 사용자의 드래그 속도를 좌표 변화에 반영할지 여부 * @param {String} [touchAction=null] Value that overrides the element's "touch-action" css property. If set to null, it is automatically set to prevent scrolling in the direction of the connected axis. 엘리먼트의 "touch-action" CSS 속성을 덮어쓰는 값. 만약 null로 설정된 경우, 연결된 축 방향으로의 스크롤을 방지하게끔 자동으로 설정된다. **/ /** @@ -150,6 +157,7 @@ export class PanInput implements InputType { preventClickOnDrag: false, iOSEdgeSwipeThreshold: IOS_EDGE_THRESHOLD, releaseOnScroll: false, + useAcceleration: false, touchAction: null, ...options, }; @@ -267,6 +275,7 @@ export class PanInput implements InputType { const { iOSEdgeSwipeThreshold, releaseOnScroll, + useAcceleration, inputKey, inputButton, threshold, @@ -339,9 +348,18 @@ export class PanInput implements InputType { } panEvent.preventSystemEvent = prevent; if (prevent && (this._isOverThreshold || distance >= threshold)) { + const velocity = useAcceleration + ? this._getVelocity(panEvent) + : undefined; this._dragged = true; this._isOverThreshold = true; - this._observer.change(this, panEvent, toAxis(this.axes, offset)); + this._observer.change( + this, + panEvent, + toAxis(this.axes, offset), + false, + velocity + ); } activeEvent.prevEvent = panEvent; } @@ -356,16 +374,7 @@ export class PanInput implements InputType { this._detachWindowEvent(activeEvent); clearTimeout(this._rightEdgeTimer); const prevEvent = activeEvent.prevEvent; - const velocity = this._isOverThreshold ? this._getOffset( - [ - Math.abs(prevEvent.velocityX) * (prevEvent.offsetX < 0 ? -1 : 1), - Math.abs(prevEvent.velocityY) * (prevEvent.offsetY < 0 ? -1 : 1), - ], - [ - useDirection(DIRECTION_HORIZONTAL, this._direction), - useDirection(DIRECTION_VERTICAL, this._direction), - ] - ) : [0, 0]; + const velocity = this._getVelocity(prevEvent); activeEvent.onRelease(); this._observer.release(this, prevEvent, velocity); } @@ -396,6 +405,21 @@ export class PanInput implements InputType { ]; } + private _getVelocity(event: ExtendedEvent): number[] { + return this._isOverThreshold + ? this._getOffset( + [ + Math.abs(event.velocityX) * (event.offsetX < 0 ? -1 : 1), + Math.abs(event.velocityY) * (event.offsetY < 0 ? -1 : 1), + ], + [ + useDirection(DIRECTION_HORIZONTAL, this._direction), + useDirection(DIRECTION_VERTICAL, this._direction), + ] + ) + : [0, 0]; + } + private _getDistance(delta: number[], direction: boolean[]): number { return Math.sqrt( Number(direction[0]) * Math.pow(delta[0], 2) + @@ -432,7 +456,11 @@ export class PanInput implements InputType { const element = this.element; if (element) { if (this.options.preventClickOnDrag) { - element.removeEventListener("click", this._preventClickWhenDragged, true); + element.removeEventListener( + "click", + this._preventClickWhenDragged, + true + ); } activeEvent?.start.forEach((event) => { element.removeEventListener(event, this._onPanstart); diff --git a/packages/axes/src/types.ts b/packages/axes/src/types.ts index 64e18172..e0c3f603 100644 --- a/packages/axes/src/types.ts +++ b/packages/axes/src/types.ts @@ -47,6 +47,7 @@ export interface AnimationParam { destPos: Axis; duration: number; delta: Axis; + triggerAnimationEvent: boolean; isTrusted?: boolean; stop?: () => void; setTo?: ( diff --git a/packages/axes/test/unit/Axes.spec.js b/packages/axes/test/unit/Axes.spec.js index 74acd015..10ec3338 100644 --- a/packages/axes/test/unit/Axes.spec.js +++ b/packages/axes/test/unit/Axes.spec.js @@ -456,7 +456,7 @@ describe("Axes", () => { }); }); - describe("Nested Axes Test", () => { + describe("nested", () => { beforeEach(() => { inst = new Axes({ x: { @@ -600,6 +600,7 @@ describe("Axes", () => { }); input = new MockPanInputInjector.PanInput(el, { iOSEdgeSwipeThreshold, + inputKey: ["any"], inputType: ["touch"], }); inst @@ -649,8 +650,8 @@ describe("Axes", () => { // Then // for test animation event setTimeout(() => { - const releaseEvent = releaseHandler.getCall(0).args[0]; expect(releaseHandler.calledOnce).to.be.true; + const releaseEvent = releaseHandler.getCall(0).args[0]; // expect(releaseEvent.inputEvent.isFinal).to.be.false; expect(releaseEvent.isTrusted).to.be.true; @@ -678,8 +679,8 @@ describe("Axes", () => { // Then // for test animation event setTimeout(() => { - const releaseEvent = releaseHandler.getCall(0).args[0]; expect(releaseHandler.calledOnce).to.be.true; + const releaseEvent = releaseHandler.getCall(0).args[0]; // expect(releaseEvent.inputEvent.isFinal).to.be.false; expect(releaseEvent.isTrusted).to.be.true; diff --git a/packages/axes/test/unit/inputType/PanInput.spec.js b/packages/axes/test/unit/inputType/PanInput.spec.js index 56ed4960..d3a43d2a 100644 --- a/packages/axes/test/unit/inputType/PanInput.spec.js +++ b/packages/axes/test/unit/inputType/PanInput.spec.js @@ -527,6 +527,53 @@ describe("PanInput", () => { }); }); + describe("useAcceleration", () => { + it(`should apply drag velocity to coordinate change if useAcceleration is true`, (done) => { + // Given + const animationStart = sinon.spy(); + const animationEnd = sinon.spy(); + input = new PanInput(el, { + inputType: ["touch", "mouse"], + scale: [10, 10], + useAcceleration: true, + }); + const result = []; + inst = new Axes({ + x: { + range: [0, 3000], + }, + }); + + inst.connect(["x"], input); + inst.on("animationStart", animationStart); + inst.on("animationEnd", animationEnd); + + // When + Simulator.gestures.pan(el, { + pos: [0, 0], + deltaX: 200, + duration: 300, + easing: "linear", + }); + + inst.on({ + change: (e) => { + result.push(e.pos.x); + }, + // Then + release: () => { + // If animation from drag velocity are played in meanwhile, some value of the coordinate will not be divisible by 10 + expect(result.every((x) => x % 10 === 0)).to.be.equal(false); + }, + finish: () => { + expect(animationStart.calledOnce).to.be.equals(true); + expect(animationEnd.calledOnce).to.be.equals(true); + done(); + }, + }); + }); + }); + describe("touchAction", () => { ["auto", "none", "manipulation", "pan-x", "pan-y"].forEach( (touchAction) => { diff --git a/packages/axes/test/unit/inputType/WheelInput.spec.js b/packages/axes/test/unit/inputType/WheelInput.spec.js index 3e8b6c65..5123f707 100644 --- a/packages/axes/test/unit/inputType/WheelInput.spec.js +++ b/packages/axes/test/unit/inputType/WheelInput.spec.js @@ -343,7 +343,7 @@ describe("WheelInput", () => { TestHelper.dispatchWheel(el, { deltaY, shiftKey: true }, () => { // Then expect(inst.axisManager.get().x).to.be.equal(10); - done(); + done(); }); }); @@ -357,23 +357,12 @@ describe("WheelInput", () => { TestHelper.dispatchWheel(el, { deltaY }, () => { // Then expect(inst.axisManager.get().x).to.be.equal(0); - done(); + done(); }); }); }); describe("useAnimation", () => { - let animationStartHandler; - let animationEndHandler; - beforeEach(() => { - animationStartHandler = sinon.spy(); - animationEndHandler = sinon.spy(); - inst.on({ - animationStart: animationStartHandler, - animationEnd: animationEndHandler, - }); - }); - it("should change coordinate smoothly by animation when useAnimation is true", (done) => { // Given const deltaY = 1; @@ -385,8 +374,6 @@ describe("WheelInput", () => { // Then expect(inst.axisManager.get().x).to.be.not.equal(10); setTimeout(() => { - expect(animationStartHandler.calledOnce).to.be.true; - expect(animationEndHandler.calledOnce).to.be.true; expect(inst.axisManager.get().x).to.be.equal(10); done(); }, 200); @@ -403,11 +390,7 @@ describe("WheelInput", () => { TestHelper.dispatchWheel(el, { deltaY }, () => { // Then expect(inst.axisManager.get().x).to.be.equal(10); - setTimeout(() => { - expect(animationStartHandler.called).to.be.false; - expect(animationEndHandler.called).to.be.false; - done(); - }, 200); + done(); }); }); }); diff --git a/packages/demo/docusaurus.config.js b/packages/demo/docusaurus.config.js index 196c4fb6..86656385 100644 --- a/packages/demo/docusaurus.config.js +++ b/packages/demo/docusaurus.config.js @@ -44,6 +44,10 @@ const config = { }) ] ], + i18n: { + defaultLocale: "en", + locales: ["en", "ko"] + }, themeConfig: /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ @@ -78,6 +82,10 @@ const config = { label: "Demos", position: "left" }, + { + type: "localeDropdown", + position: "right" + }, { href: "https://www.npmjs.com/package/@egjs/axes", className: "header-npm-link", @@ -132,7 +140,12 @@ const config = { prism: { theme: lightCodeTheme, darkTheme: darkCodeTheme - } + }, + algolia: { + appId: 'XFF4246J3M', + apiKey: '637cea2eb4f4992a666d77239ba5cfc2', + indexName: 'egjs-axes', + }, }) }; diff --git a/packages/demo/src/pages/Options.mdx b/packages/demo/src/pages/Options.mdx index 9cc682ce..fe81b85c 100644 --- a/packages/demo/src/pages/Options.mdx +++ b/packages/demo/src/pages/Options.mdx @@ -293,6 +293,7 @@ Rounding unit. For example, 0.1 rounds to 0.1 decimal point(6.1234 => 6.1), 5 ro + ### nested Whether the event propagates to other instances when the coordinates reach the end of the movable area. diff --git a/packages/demo/src/pages/demos/axesboard.tsx b/packages/demo/src/pages/demos/axesboard.tsx index 1b9097de..569a78fa 100644 --- a/packages/demo/src/pages/demos/axesboard.tsx +++ b/packages/demo/src/pages/demos/axesboard.tsx @@ -63,6 +63,14 @@ export default function AxesBoard({ axis, demoType, options, panInputOptions, pi const maxHeight = boardHeight - targetHeight; const xRange = axis && axis.x && axis.x.range && axis.x.range[1]; const yRange = axis && axis.y && axis.y.range && axis.y.range[1]; + + window.addEventListener("resize", () => { + const board = document.getElementById("board"); + const newWidth = board.getBoundingClientRect().width; + const newHeight = board.getBoundingClientRect().height; + setTo({ x: (newWidth - targetWidth) / 2, y: (newHeight - targetHeight) / 2 }); + }); + if (isNested) { const innerBoardWidth = innerBoard.current.getBoundingClientRect().width; const innerBoardHeight = innerBoard.current.getBoundingClientRect().height; @@ -122,7 +130,7 @@ export default function AxesBoard({ axis, demoType, options, panInputOptions, pi }, []); return options?.nested ? ( -
+
x: {x} y: {y}
@@ -138,7 +146,7 @@ export default function AxesBoard({ axis, demoType, options, panInputOptions, pi
) : ( -
+
x: {x} y: {y}
{ onClick(); }}>