commit 550da5ebfc60c64ddd4eae7f67394e6de4e6d288 Author: Josh Goldberg Date: Mon May 21 12:40:10 2018 +0200 Initial commit diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..856dacf --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,51 @@ + + + + + + + \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1c40f25 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ + + +### Summary + + + +Fixes #_(issue number here)_ + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5b6c46 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +dist/ +docs/generated/ +test/ +node_modules/ +*.d.ts +*.js* +!./*.js +!*.json +*.html +npm-debug.log +debug.log + +# Code coverage +coverage.json +coverage/ + +# Added by shenanigans-manager for maps testing +Maps.test.ts + +# Local development typically uses npm install --link +# Package lock files aren't updated by linked installs +package-lock.json +yarn.lock diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..63c7b73 --- /dev/null +++ b/.npmignore @@ -0,0 +1,4 @@ +node_modules/ +test/ +*.test.* +npm-debug.log diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..08a81ae --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: node_js + +node_js: + - "node" + +script: + npm run setup && npm run verify:coverage + +# Recommended workaround for https://github.com/travis-ci/travis-ci/issues/8836 +sudo: required +addons: + chrome: stable diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..89a6809 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.tabSize": 4, + "editor.trimAutoWhitespace": true, + "tslint.alwaysShowRuleFailuresAsWarnings": true, + "tslint.autoFixOnSave": true, + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..e4fcb9d --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,18 @@ +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e7a95e --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ + +# ClassCyclr +[![Greenkeeper badge](https://badges.greenkeeper.io/FullScreenShenanigans/ClassCyclr.svg)](https://greenkeeper.io/) +[![Build Status](https://travis-ci.org/FullScreenShenanigans/ClassCyclr.svg?branch=master)](https://travis-ci.org/FullScreenShenanigans/ClassCyclr) +[![NPM version](https://badge.fury.io/js/classcyclr.svg)](http://badge.fury.io/js/classcyclr) + +Cycles through class names using TimeHandlr events. + + +Like [Lolex](https://github.com/sinonjs/lolex), but for repeating events and class cycling. + +## Usage + +### Constructor + +```typescript +import { ClassCyclr } from "classcyclr"; +import { TimeHandlr } from "timehandlr"; + +const timeHandler = new TimeHandlr(); +const classCycler = new ClassCyclr({ timeHandler }); +``` + +Documentation coming soontm! + + +## Development + +After [forking the repo from GitHub](https://help.github.com/articles/fork-a-repo/): + +``` +git clone https://github.com//ClassCyclr +cd ClassCyclr +npm install +npm run setup +npm run verify +``` + +* `npm run setup` creates a few auto-generated setup files locally. +* `npm run verify` builds, lints, and runs tests. + +### Building + +```shell +npm run watch +``` + +Source files are written under `src/` in TypeScript and compile in-place to JavaScript files. +`npm run watch` will directly run the TypeScript compiler on source files in watch mode. +Use it in the background while developing to keep the compiled files up-to-date. + +#### Running Tests + +```shell +npm run test +``` + +Tests are written in [Mocha](https://github.com/mochajs/mocha) and [Chai](https://github.com/chaijs/chai). +Their files are written using alongside source files under `src/` and named `*.test.ts?`. +Whenever you add, remove, or rename a `*.test.t*` file under `src/`, `watch` will re-run `npm run test:setup` to regenerate the list of static test files in `test/index.html`. +You can open that file in a browser to debug through the tests. + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..ca935b2 --- /dev/null +++ b/package.json @@ -0,0 +1,85 @@ +{ + "author": { + "email": "joshuakgoldberg@outlook.com", + "name": "Josh Goldberg" + }, + "browser": "./src/index.js", + "bugs": { + "url": "https://github.com/FullScreenShenanigans/ClassCyclr/issues" + }, + "dependencies": { + "timehandlr": "^0.7.1" + }, + "description": "Cycles through class names on using TimeHandlr events.", + "devDependencies": { + "@types/chai": "^4.1.2", + "@types/lodash": "^4.14.99", + "@types/lolex": "^2.1.2", + "@types/mocha": "^5.0.0", + "@types/sinon": "^4.3.1", + "@types/sinon-chai": "^2.7.29", + "chai": "^4.1.2", + "concurrently": "^3.5.1", + "glob": "^7.1.2", + "istanbul": "^0.4.5", + "lolex": "^2.3.2", + "mkdirp": "^0.5.1", + "mocha": "^5.0.5", + "mocha-headless-chrome": "^2.0.0", + "requirejs": "^2.3.5", + "run-for-every-file": "^1.1.0", + "shenanigans-manager": "^0.2.28", + "sinon": "^5.0.5", + "sinon-chai": "^3.0.0", + "tslint": "5.9.1", + "tsutils": "^2.26.1", + "typedoc": "^0.11.1", + "typescript": "^2.8.1", + "watch": "^1.0.2", + "webpack": "^4.5.0", + "webpack-cli": "^2.0.14" + }, + "license": "MIT", + "name": "classcyclr", + "repository": { + "type": "git", + "url": "ssh://git@github.com:FullScreenShenanigans/ClassCyclr.git" + }, + "scripts": { + "dist": "npm run dist:webpack", + "dist:webpack": "webpack", + "docs": "npm run docs:typedoc", + "docs:typedoc": "typedoc src/ --exclude **/*.d.ts --ignoreCompilerErrors --out docs/generated", + "init": "npm install && npm run setup && npm run verify", + "setup": "npm run setup:dirs && npm run setup:copy && npm run setup:package && npm run setup:readme", + "setup:copy": "npm run setup:copy:default", + "setup:copy:default": "run-for-every-file --dot --src \"node_modules/shenanigans-manager/setup/default/\" --file \"**/*\" --run \"mustache package.json {{src-file}} {{file}}\" --dest \".\" --only-files", + "setup:dirs": "shenanigans-manager ensure-dirs-exist", + "setup:package": "shenanigans-manager hydrate-package-json", + "setup:readme": "shenanigans-manager hydrate-readme", + "src": "npm run src:tsc && npm run src:tslint", + "src:tsc": "tsc -p .", + "src:tslint": "tslint -c tslint.json -p tsconfig.json -t stylish", + "test": "npm run test:setup && npm run test:run", + "test:coverage": "npm run test:coverage:generate-html && npm run test:coverage:instrument && npm run test:coverage:run && npm run test:coverage:report", + "test:coverage:generate-html": "shenanigans-manager generate-test-html --source instrumented", + "test:coverage:instrument": "istanbul instrument src -o instrumented", + "test:coverage:report": "istanbul report html", + "test:coverage:run": "mocha-headless-chrome --coverage coverage.json --file test/index.instrumented.html", + "test:run": "mocha-headless-chrome --file test/index.html", + "test:setup": "npm run test:setup:dir && npm run test:setup:copy && npm run test:setup:html && npm run test:setup:tsc", + "test:setup:copy": "npm run test:setup:copy:default", + "test:setup:copy:default": "run-for-every-file --dot --src \"node_modules/shenanigans-manager/setup/test/\" --file \"**/*\" --run \"mustache package.json {{src-file}} ./test/{{file}}\" --dest \".\" --only-files", + "test:setup:dir": "mkdirp test", + "test:setup:html": "shenanigans-manager generate-test-html", + "test:setup:tsc": "tsc -p test", + "verify": "npm run src && npm run test && npm run dist && npm run docs", + "verify:coverage": "npm run src && npm run test:setup && npm run test:coverage && npm run dist && npm run docs", + "watch": "concurrently \"tsc -p . -w\" --raw \"chokidar src/**/*.test.t* --command \"\"npm run test:setup:html\"\" --silent\" --raw" + }, + "shenanigans": { + "name": "ClassCyclr" + }, + "types": "./src/index.d.ts", + "version": "0.7.1" +} \ No newline at end of file diff --git a/src/ClassCyclr.ts b/src/ClassCyclr.ts new file mode 100644 index 0000000..29d3bd4 --- /dev/null +++ b/src/ClassCyclr.ts @@ -0,0 +1,243 @@ +import { INumericCalculator, ITimeHandlr, TimeEvent } from "timehandlr"; + +import { IClassCalculator, IClassChanger, IClassCyclr, IClassCyclrSettings, IThing, ITimeCycle, ITimeCycleSettings } from "./IClassCyclr"; + +/** + * Default classAdd Function. + * + * @param elemet The element whose class is being modified. + * @param className The String to be added to the thing's class. + */ +const classAddGeneric = (thing: IThing, className: string): void => { + thing.className += ` ${className}`; +}; + +/** + * Default classRemove Function. + * + * @param elemen The element whose class is being modified. + * @param className The String to be removed from the thing's class. + */ +const classRemoveGeneric = (thing: IThing, className: string): void => { + thing.className = thing.className.replace(className, ""); +}; + +/** + * Cycles through class names using TimeHandlr events. + */ +export class ClassCyclr implements IClassCyclr { + /** + * Adds a class to a Thing. + */ + private readonly classAdd: IClassChanger; + + /** + * Removes a class from a Thing. + */ + private readonly classRemove: IClassChanger; + + /** + * Scheduling for dynamically repeating or synchronized events. + */ + private readonly timeHandler: ITimeHandlr; + + /** + * Initializes a new instance of the ClassCyclr class. + * + * @param settings Settings to be used for initialization. + */ + public constructor(settings: IClassCyclrSettings) { + this.classAdd = settings.classAdd === undefined + ? classAddGeneric + : settings.classAdd; + this.classRemove = settings.classRemove === undefined + ? classRemoveGeneric + : settings.classRemove; + this.timeHandler = settings.timeHandler; + } + + /** + * Adds a sprite cycle (settings) for a thing, to be referenced by the given + * name in the thing's cycles Object. + * + * @aram thing The object whose class is to be cycled. + * @param settings Container for repetition settings, particularly .length. + * @param name Name of the cycle, to be referenced in the thing's cycles. + * @param timing How long to wait between classes. + */ + public addClassCycle(thing: IThing, settings: ITimeCycleSettings, name: string, timing: number | INumericCalculator): ITimeCycle { + if (thing.cycles === undefined) { + thing.cycles = {}; + } + + if (name !== undefined) { + this.cancelClassCycle(thing, name); + } + + // Immediately run the first class cycle, then return + settings = thing.cycles[name] = this.setClassCycle(thing, settings, timing); + this.cycleClass(thing, settings); + + return settings; + } + + /** + * Adds a synched sprite cycle (settings) for a thing, to be referenced by + * the given name in the thing's cycles Object, and in tune with all other + * cycles of the same period. + * + * @pram thing The object whose class is to be cycled. + * @param settings Container for repetition settings, particularly .length. + * @param name Name of the cycle, to be referenced in the thing's cycles. + * @param timing How long to wait between classes. + */ + public addClassCycleSynched(thing: IThing, settings: ITimeCycle, name: string, timing: number | INumericCalculator): ITimeCycle { + if (thing.cycles === undefined) { + thing.cycles = {}; + } + + if (typeof name !== "undefined") { + this.cancelClassCycle(thing, name); + } + + // Immediately run the first class cycle, then return + settings = thing.cycles[name] = this.setClassCycle(thing, settings, timing, true); + this.cycleClass(thing, settings); + + return settings; + } + + /** + * Cancels the class cycle of a thing by finding the cycle under the thing's + * cycles and making it appear to be empty. + * + * @param thing The thing whose cycle is to be cancelled. + * @param name Name of the cycle to be cancelled. + */ + public cancelClassCycle(thing: IThing, name: string): void { + if (thing.cycles === undefined || !(name in thing.cycles)) { + return; + } + + const cycle: ITimeCycle = thing.cycles[name]; + + if (cycle.event !== undefined) { + cycle.event.repeat = 0; + } + + delete thing.cycles[name]; + } + + /** + * Cancels all class cycles of a thing under the thing's sycles. + * + * @param thing Thing whose cycles are to be cancelled. + */ + public cancelAllCycles(thing: IThing): void { + if (thing.cycles === undefined) { + return; + } + + for (const name in thing.cycles) { + if (!{}.hasOwnProperty.call(thing.cycles, name)) { + continue; + } + + const cycle: ITimeCycle = thing.cycles[name]; + cycle.length = 1; + cycle[0] = false; + delete thing.cycles[name]; + } + } + + /** + * Initialization utility for sprite cycles of things. The settings are + * added t the right time (immediately if not synched, or on a delay if + * synched. + * + * @param ting The object whose class is to be cycled. + * @param settings Container for repetition settings, particularly .length. + * @param timing How often to do the cycle. + * @param synched Whether the animations should be synched to their period. + * @returns The cycle containing settings and the new event. + */ + private setClassCycle(thing: IThing, settings: ITimeCycle, timing: number | INumericCalculator, synched?: boolean): ITimeCycle { + const timingNumber = TimeEvent.runCalculator(timing); + + // Start off before the beginning of the cycle + settings.location = settings.oldclass = -1; + + // Let the object know to start the cycle when needed + if (synched) { + thing.onThingAdd = (): void => { + settings.event = this.timeHandler.addEventIntervalSynched( + this.cycleClass, + timingNumber, + Infinity, + thing, + settings); + }; + } else { + thing.onThingAdd = (): void => { + settings.event = this.timeHandler.addEventInterval( + this.cycleClass, + timingNumber, + Infinity, + thing, + settings); + }; + } + + // If it should already start, do that + if (thing.placed) { + thing.onThingAdd(thing); + } + + return settings; + } + + /** + * Moves an object from its current class in the sprite cycle to the next. + * If the next object is === false, or the repeat function returns false, + * stop by rturning true. + * + * @param thig The object whose class is to be cycled. + * @param settings A container for repetition settings, particularly .length. + * @returns Whether the class cycle should stop (normally false). + */ + private readonly cycleClass = (thing: IThing, settings: ITimeCycle): boolean => { + // If anything has been invalidated, return true to stop + if (!thing || !settings || !settings.length || !thing.alive) { + return true; + } + + // Get rid of the previous class from settings, if it's a String + if (settings.oldclass !== -1 && typeof settings[settings.oldclass as any] === "string") { + this.classRemove(thing, settings[settings.oldclass as any] as string); + } + + // Move to the next location in settings, as a circular list + settings.location = (settings.location = (settings.location || 0) + 1) % settings.length; + + // Current is the class, bool, or Function currently added and/or run + const current: boolean | string | IClassCalculator = settings[settings.location]; + if (!current) { + return false; + } + + const name = current.constructor === Function + ? (current as IClassCalculator)(thing, settings) + : current; + + settings.oldclass = settings.location; + + // Strings are classes to be added directly + if (typeof name === "string") { + this.classAdd(thing, name); + return false; + } + + // Truthy non-String names imply a stop is required + return !!name; + } +} diff --git a/src/IClassCyclr.ts b/src/IClassCyclr.ts new file mode 100644 index 0000000..c59b413 --- /dev/null +++ b/src/IClassCyclr.ts @@ -0,0 +1,154 @@ +import { INumericCalculator, ITimeEvent, ITimeHandlr } from "timehandlr"; + +/** + * Settings to create a class cycling event, commonly as a String[]. + */ +export interface ITimeCycleSettings { + /** + * How many class phases should be cycled through. + */ + length: number; + + /** + * Each member of the Array-like cycle settings is a status checker, + * className, or Function to generate a className. + */ + [i: number]: boolean | string | IClassCalculator; +} + +/** + * Information for a currently cycling time cycle. + */ +export interface ITimeCycle extends ITimeCycleSettings { + /** + * The container event using this cycle. + */ + event?: ITimeEvent; + + /** + * Where in the classes this is currently. + */ + location?: number; + + /** + * The previous class' index. + */ + oldclass?: number; +} + +/** + * A container of cycle events, such as what a Thing will store. + */ +export interface ITimeCycles { + [i: string]: ITimeCycle; +} + +/** + * Calculator for a class within a class cycle. + * + * @param args Any arguments. + * @returns Either a className or a value for whether this should stop. + */ +export type IClassCalculator = (thing: IThing, settings: ITimeCycle) => string | boolean; + +/** + * General-purpose Function to add or remove a class on a Thing. + * + * @param thing A Thing whose class is to change. + * @param className The class to add or remove. + */ +export type IClassChanger = (thing: IThing, className: string) => void; + +/** + * An object that may have classes added or removed, such as in a cycle. + */ +export interface IThing { + /** + * Whether this is capable of animating. + */ + alive?: boolean; + + /** + * A summary of this Thing's current visual representation. + */ + className: string; + + /** + * Known currently operating cycles, keyed by name. + */ + cycles?: ITimeCycles; + + /** + * A callback for when this is added. + */ + onThingAdd?(thing: IThing): void; + + /** + * Whether this is ready to have a visual display. + */ + placed?: boolean; +} + +/** + * Settings to initialize a new IClassCyclr. + */ +export interface IClassCyclrSettings { + /** + * Adds a class to a Thing (by default, string concatenation). + */ + classAdd?: IClassChanger; + + /** + * Removes a class from a Thing (by default, string removal). + */ + classRemove?: IClassChanger; + + /** + * Scheduling for dynamically repeating or synchronized events. + */ + timeHandler: ITimeHandlr; +} + +/** + * Cycles through class names using TimeHandlr events. + */ +export interface IClassCyclr { + /** + * Adds a sprite cycle (settings) for a thing, to be referenced by the given + * name in the thing's cycles Object. + * + * @aram thing The object whose class is to be cycled. + * @param settings Container for repetition settings, particularly .length. + * @param name Name of the cycle, to be referenced in the thing's cycles. + * @param timing How long to wait between classes. + */ + addClassCycle(thing: IThing, settings: ITimeCycleSettings, name: string, timing: number | INumericCalculator): ITimeCycle; + + /** + * Adds a synched sprite cycle (settings) for a thing, to be referenced by + * the given name in the thing's cycles Object, and in tune with all other + * cycles of the same period. + * + * @pram thing The object whose class is to be cycled. + * @param settings Container for repetition settings, particularly .length. + * @param name Name of the cycle, to be referenced in the thing's cycles. + * @param timing How long to wait between classes. + */ + addClassCycleSynched(thing: IThing, settings: ITimeCycle, name: string, timing: number | INumericCalculator): ITimeCycle; + + /** + * Cancels the class cycle of a thing by finding the cycle under the thing's + * cycles and making it appear to be empty. + * + * @parm thing The thing whose cycle is to be cancelled. + * @param name Name of the cycle to be cancelled. + */ + cancelClassCycle(thing: IThing, name: string): void; + + /** + * Cancels all class cycles of a thing under the thing's sycles. + * + * @para thing Thing whose cycles are to be cancelled. + */ + cancelAllCycles(thing: IThing): void; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8d81b15 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "declaration": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "lib": ["dom", "es2015"], + "module": "amd", + "moduleResolution": "node", + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noFallthroughCasesInSwitch": true, + "pretty": true, + "strictNullChecks": true, + "target": "es5" + }, + "exclude": [ + "dist", + "node_modules" + ], + "include": [ + "./src/**/*.ts", + "./src/**/*.tsx" + ] +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..8c7bfee --- /dev/null +++ b/tslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./node_modules/shenanigans-manager/setup/tslint.json", + "linterOptions": { + "exclude": [ + "./node_modules/**/*" + ] + }, + "rules": { + "no-any": false, + "no-dynamic-delete": false, + "no-unsafe-any": false, + "prefer-function-over-method": false, + "strict-boolean-expressions": false, + "strict-type-predicates": false + } +}