Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: cettoana/react-scramble
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v0.0.2
Choose a base ref
...
head repository: cettoana/react-scramble
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: master
Choose a head ref

Commits on May 18, 2018

  1. Create LICENSE

    cettoana authored May 18, 2018
    Copy the full SHA
    844bed9 View commit details
  2. Copy the full SHA
    f8702ec View commit details
  3. v0.1.0

    cettoana committed May 18, 2018
    Copy the full SHA
    c0489f7 View commit details
  4. doc(demo): update style

    cettoana committed May 18, 2018
    Copy the full SHA
    a237451 View commit details

Commits on May 20, 2018

  1. Update package.json

    cettoana committed May 20, 2018
    Copy the full SHA
    14f95a3 View commit details
  2. 0.1.1

    cettoana committed May 20, 2018
    Copy the full SHA
    625d2c8 View commit details

Commits on May 30, 2018

  1. Copy the full SHA
    e53944f View commit details
  2. 0.1.2

    cettoana committed May 30, 2018
    Copy the full SHA
    6370fcf View commit details

Commits on May 31, 2018

  1. fix: error after reset

    cettoana committed May 31, 2018
    Copy the full SHA
    cf7a82c View commit details
  2. feat: add noBreakSpace prop

    cettoana committed May 31, 2018
    Copy the full SHA
    8acb1f7 View commit details
  3. 0.2.0

    cettoana committed May 31, 2018
    Copy the full SHA
    cf46295 View commit details
  4. Copy the full SHA
    bac53a7 View commit details

Commits on Jun 5, 2018

  1. upgrade rxjs to v6

    cettoana committed Jun 5, 2018
    Copy the full SHA
    4731184 View commit details
  2. chore: setup test

    cettoana committed Jun 5, 2018
    Copy the full SHA
    e0c4d30 View commit details
  3. doc: update README.md

    cettoana committed Jun 5, 2018
    Copy the full SHA
    a9134bd View commit details

Commits on Apr 22, 2019

  1. Copy the full SHA
    6c7292a View commit details

Commits on May 2, 2019

  1. Merge pull request #1 from shanekoss/master

    update recompose version to work with react 16.8.6
    cettoana authored May 2, 2019
    Copy the full SHA
    0e32eca View commit details
  2. 0.4.2

    cettoana committed May 2, 2019
    Copy the full SHA
    66e5978 View commit details

Commits on May 3, 2019

  1. update yarn.lock

    cettoana committed May 3, 2019
    Copy the full SHA
    2cbeb60 View commit details

Commits on Mar 26, 2021

  1. Copy the full SHA
    27be0a5 View commit details
  2. 0.5.1

    cettoana committed Mar 26, 2021
    Copy the full SHA
    741c5b6 View commit details
42 changes: 28 additions & 14 deletions .babelrc
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
{
"presets": [
[
"env",
{
"modules": false
}
],
"react",
"stage-1"
],
"plugins": [
"external-helpers",
"ramda"
]
"env": {
"test": {
"presets": [
"env",
"react",
"stage-1"
],
"plugins": [
"ramda"
]
},
"development": {
"presets": [
[
"env",
{
"modules": false
}
],
"react",
"stage-1"
],
"plugins": [
"external-helpers",
"ramda"
]
}
}
}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -4,6 +4,9 @@
# production
/dist

# test coverage
/coverage

# misc
.DS_Store

9 changes: 9 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -4,9 +4,18 @@
# dependencies
/node_modules

# test coverage
/coverage

# source
/src

# media
/media

# misc
.DS_Store

.babelrc
.eslintrc
.gitignore
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Changelog

## [Unreleased]

## [0.4.2] - 2019-05-02

- Upgrade recompose@^0.30.0 [@shanekoss](https://github.com/shanekoss)

- Upgrade rxjs to v6.

- Remove unused files in npm package.

## [0.2.0] - 2018-05-31

### Added

- Add `noBreakSpace` prop to Scramble component.

### Fixed

- Break after reset under certain circumstances.

## [0.1.2] - 2018-05-30

### Fixed

- Omit `restart` from props before passing to `span`.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2018 Abel Chen

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.
42 changes: 21 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -6,10 +6,13 @@
<br>
</h1>

[![npm](https://img.shields.io/npm/v/react-scramble.svg)](https://www.npmjs.com/package/react-scramble)
[![npm version](https://img.shields.io/npm/v/react-scramble.svg?style=flat-square)](https://www.npmjs.com/package/react-scramble)
[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](#badge)

React component for text scramble animation.

[Live Demo](https://cettoana.github.io/react-scramble)

## Installation

```bash
@@ -47,33 +50,30 @@ class App extends React.Compoent {

Remember to use `monospace` fonts to make it looks better.

## Live Demo

See the [live demo](https://cettoana.github.io/react-scramble-draft)

## Step Format

Each step is an `Object` with following keys:

| Key | Type | Default | Description |
| :----------------- | :------- | :-------- | :-------------------------------------------------------------------------------------- |
| action | string | | Action of the step, `+` as scramble, `-` as descramble and leave blank for do nothing. |
| roll | number | | Times of action in the step. |
| text | string | | Change the original text. |
| type | string | `all` | Scramble/descrmble type of the step, one of `all`, `random`, `forward`. |
| Key | Type | Default | Description |
| :----- | :----- | :------ | :------------------------------------------------------------------------------------- |
| action | string | | Action of the step, `+` as scramble, `-` as descramble and leave blank for do nothing. |
| roll | number | | Times of action in the step. |
| text | string | | Change the original text. |
| type | string | `all` | Scramble/descrmble type of the step, one of `all`, `random`, `forward`. |

## Scramble Props

| Property | Type | Default | Description |
| :----------------- | :------- | :-------- | :-------------------------------------------------------------------------------- |
| autoStart | boolean | false | Set `true` to auto start animation after render. |
| bindMethod | function | | Method binding callback function, see [Bind Methods](#bind-methods). |
| mouseEnterTrigger | string | | Event trigger type when mouse enter, one of `start`, `pause`, `reset`, `restart`. |
| mouseLeaveTrigger | string | | Event trigger type when mouse leave, one of `start`, `pause`, `reset`, `restart`. |
| preScramble | boolean | false | Scramble the text after render. |
| speed | string | `medium` | Speed of scramble per second, one of `slow`, `mediun`, `fast`. |
| steps | array | | Scramble steps, a list of `Object` in [Step](#step-format) format. |
| text | string | | Original text. |
| Property | Type | Default | Description |
| :---------------- | :------- | :------- | :-------------------------------------------------------------------------------- |
| autoStart | boolean | false | Set `true` to auto start animation after render. |
| bindMethod | function | | Method binding callback function, see [Bind Methods](#bind-methods). |
| mouseEnterTrigger | string | | Event trigger type when mouse enter, one of `start`, `pause`, `reset`, `restart`. |
| mouseLeaveTrigger | string | | Event trigger type when mouse leave, one of `start`, `pause`, `reset`, `restart`. |
| noBreakSpace | boolean | true | Using no-break space or not. |
| preScramble | boolean | false | Scramble the text after render. |
| speed | string | `medium` | Speed of scramble per second, one of `slow`, `mediun`, `fast`. |
| steps | array | | Scramble steps, a list of `Object` in [Step](#step-format) format. |
| text | string | | Original text. |

## Bind Methods

2 changes: 1 addition & 1 deletion example/src/App.css
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@
}

.App > section:nth-child(2n) {
background-color: #EFEFEF;
background-color: #efefef;
}

.App-examples-wrap {
9 changes: 0 additions & 9 deletions example/src/App.test.js

This file was deleted.

3 changes: 1 addition & 2 deletions example/src/components/Title.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
.Title-container {
width: 265px;
text-align: left;
font-family: 'Consolas', monospace, monospace;
font-size: 32px;
@@ -13,5 +12,5 @@
}

.Title-container span:nth-child(3) {
color: #BBBBBB;
color: #bbbbbb;
}
13 changes: 6 additions & 7 deletions example/src/components/Title.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react'
import Scramble from 'react-scramble'
import Scramble, { getNoBreakSpaces } from 'react-scramble'
import './Title.css'

const Title = () => (
@@ -11,7 +11,7 @@ const Title = () => (
steps={[
{
roll: 10,
text: "Scramble React",
text: 'Scramble React',
},
{
roll: 10,
@@ -40,7 +40,7 @@ const Title = () => (
/>
<Scramble
autoStart
text="_______________"
text={getNoBreakSpaces(15)}
steps={[
{ roll: 40 },
{
@@ -50,7 +50,7 @@ const Title = () => (
},
{
roll: 10,
text: "like this one,",
text: 'like this one,',
},
{
roll: 30,
@@ -61,18 +61,17 @@ const Title = () => (
/>
<Scramble
autoStart
text="_______________"
text={getNoBreakSpaces(15)}
steps={[
{ roll: 50 },
{
roll: 30,
type: 'all',
action: '+',

},
{
roll: 35,
text: "and this :)",
text: 'and this :)',
},
{
roll: 20,
6 changes: 3 additions & 3 deletions example/src/examples/ScrambleCommon.css
Original file line number Diff line number Diff line change
@@ -10,13 +10,13 @@
margin-left: 16px;
color: #333;
border-color: #333;
background-color: #F9F9F9;
outline:0;
background-color: #f9f9f9;
outline: 0;
cursor: pointer;
}

.Scramble-section button:active {
background-color: #EEEEEE;
background-color: #eeeeee;
}

.Scramble-section button:first-of-type {
56 changes: 28 additions & 28 deletions example/src/examples/ScrambleCommon.js
Original file line number Diff line number Diff line change
@@ -7,34 +7,34 @@ import './ScrambleCommon.css'
class ScrambleCommon extends React.Component {
render() {
return (
<section className="Scramble-section">
<div>
<Scramble
className="Scramble-common"
preScramble={this.props.preScramble}
text={this.props.text}
bindMethod={c => {
this.setState({
start: c.start,
pause: c.pause,
reset: c.reset,
})
}}
steps={this.props.steps}
/>
</div>
<div>
<button onClick={() => this.state.start()}>
<i className="fas fa-play"></i>
</button>
<button onClick={() => this.state.pause()}>
<i className="fas fa-pause"></i>
</button>
<button onClick={() => this.state.reset()}>
<i className="fas fa-redo"></i>
</button>
</div>
</section>
<section className="Scramble-section">
<div>
<Scramble
className="Scramble-common"
preScramble={this.props.preScramble}
text={this.props.text}
bindMethod={c => {
this.setState({
start: c.start,
pause: c.pause,
reset: c.reset,
})
}}
steps={this.props.steps}
/>
</div>
<div>
<button onClick={() => this.state.start()}>
<i className="fas fa-play" />
</button>
<button onClick={() => this.state.pause()}>
<i className="fas fa-pause" />
</button>
<button onClick={() => this.state.reset()}>
<i className="fas fa-redo" />
</button>
</div>
</section>
)
}
}
3,133 changes: 2,118 additions & 1,015 deletions example/yarn.lock

Large diffs are not rendered by default.

42 changes: 35 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,33 +1,61 @@
{
"name": "react-scramble",
"version": "0.0.2",
"version": "0.5.1",
"main": "dist/index.js",
"license": "MIT",
"scripts": {
"build": "rollup -c",
"build:demo": "cd example && yarn build",
"deploy:demo": "gh-pages -d example/build"
"deploy:demo": "gh-pages -d example/build",
"precommit": "lint-staged",
"test": "jest"
},
"lint-staged": {
"{src,example/src}/**/*.{js,css}": [
"prettier --single-quote --no-semi --trailing-comma es5 --write",
"git add"
]
},
"repository": {
"type": "git",
"url": "https://github.com/cettoana/react-scramble.git"
},
"dependencies": {
"prop-types": "^15.6.1",
"ramda": "^0.25.0",
"recompose": "^0.30.0",
"rxjs": "^6.2.0"
},
"peerDependencies": {
"react": "^16.3.0",
"react-dom": "^16.3.0",
"recompose": "^0.26.0",
"rxjs": "^5.5.8"
"react-dom": "^16.3.0"
},
"devDependencies": {
"babel-core": "^6.26.0",
"babel-eslint": "^8.2.2",
"babel-jest": "^23.0.1",
"babel-plugin-external-helpers": "^6.22.0",
"babel-plugin-ramda": "^1.5.0",
"babel-preset-env": "^1.6.1",
"babel-preset-env": "^1.7.0",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-1": "^6.24.1",
"eslint": "^4.19.1",
"eslint-plugin-react": "^7.7.0",
"gh-pages": "^1.1.0",
"husky": "^0.14.3",
"jest": "^23.1.0",
"lint-staged": "^7.1.3",
"prettier": "1.13.4",
"react-test-renderer": "^16.4.0",
"rollup": "^0.57.1",
"rollup-plugin-babel": "^3.0.3"
}
},
"jest": {
"coverageDirectory": "./coverage/",
"collectCoverage": true
},
"keywords": [
"react",
"react-component"
]
}
294 changes: 294 additions & 0 deletions src/Scramble.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
import * as React from 'react'
import setObservableConfig from 'recompose/setObservableConfig'
import createEventHandler from 'recompose/createEventHandler'
import compose from 'recompose/compose'
import mapPropsStream from 'recompose/mapPropsStream'
import withHandlers from 'recompose/withHandlers'
import withPropsOnChange from 'recompose/withPropsOnChange'
import lifecycle from 'recompose/lifecycle'
import {
pluck,
distinctUntilChanged,
share,
map,
filter,
startWith,
mapTo,
withLatestFrom,
switchMap,
} from 'rxjs/operators'
import { Subject, merge, combineLatest, empty, interval, from } from 'rxjs'
import R from 'ramda'
import PropTypes from 'prop-types'

import { scramble, descramble, mixcramble } from './utils/scramblers'
import { getRandomMask, getFullMask, getForwardMask } from './utils/getMask'

const config = {
fromESObservable: from,
toESObservable: stream => stream,
}

setObservableConfig(config)

const omitProps = [
'autoStart',
'bindMethod',
'mouseEnterTrigger',
'mouseLeaveTrigger',
'noBreakSpace',
'pause',
'preScramble',
'reset',
'restart',
'speed',
'start',
'steps',
'text',
]

const speed = {
fast: 25,
medium: 50,
slow: 100,
}

export const getPauserStream = (
autoStart$,
isQueueEmpty$,
pause$,
reset$,
start$
) =>
merge(
combineLatest(autoStart$, reset$.pipe(startWith(''))).pipe(
map(R.head),
map(R.not)
),
pause$.pipe(mapTo(true)),
start$.pipe(
withLatestFrom(isQueueEmpty$),
map(R.nth(1))
),
isQueueEmpty$.pipe(filter(R.identity))
)

export const getPropStream = (props$, key) =>
props$.pipe(
pluck(key),
distinctUntilChanged()
)

const Scramble = compose(
mapPropsStream(props$ => {
const { handler: start, stream: start$ } = createEventHandler()
const { handler: pause, stream: pause$ } = createEventHandler()
const { handler: reset, stream: reset$ } = createEventHandler()
const queue$ = new Subject()
const counter$ = new Subject()
const result$ = new Subject()

const autoStart$ = getPropStream(props$, 'autoStart')
const preScramble$ = getPropStream(props$, 'preScramble')
const noBreakSpace$ = getPropStream(props$, 'noBreakSpace')

const initText$ = getPropStream(props$, 'text').pipe(share())
const steps$ = getPropStream(props$, 'steps').pipe(share())
const period$ = getPropStream(props$, 'speed').pipe(
map(R.prop(R.__, speed))
)

const currentStep$ = queue$.pipe(
map(R.pathOr({}, [0])),
share()
)

const isQueueEmpty$ = queue$.pipe(
map(
R.pipe(
R.length,
R.equals(0)
)
),
share()
)

const text$ = merge(
currentStep$.pipe(
pluck('text'),
filter(R.is(String))
),
combineLatest(initText$, reset$.pipe(startWith(''))).pipe(map(R.head))
).pipe(distinctUntilChanged())

const pauser$ = getPauserStream(
autoStart$,
isQueueEmpty$,
pause$,
reset$,
start$
)

const processor$ = currentStep$.pipe(
map(({ action, type }) => {
switch (action) {
case '+':
return scramble
case '-':
return type === 'forward' ? mixcramble : descramble
default:
return R.identity
}
})
)

const mask$ = combineLatest(currentStep$, counter$, result$, text$).pipe(
map(([{ type, roll }, counter, result, text]) => {
const length = R.max(result.length, text.length)

switch (type) {
case 'random':
return getRandomMask(length)
case 'forward':
return getForwardMask(length, roll, counter)
case 'all':
default:
return getFullMask(length)
}
})
)

const pausableTimer$ = combineLatest(pauser$, period$).pipe(
switchMap(
([paused, period]) =>
paused
? empty()
: // startWith 0 to send event immediately
interval(period).pipe(startWith(0))
)
)

merge(
combineLatest(initText$, preScramble$, reset$.pipe(startWith(''))).pipe(
map(
([text, preScramble]) =>
preScramble ? scramble(null, text, getFullMask(text.length)) : text
)
),
pausableTimer$.pipe(
withLatestFrom(result$, text$, processor$, mask$, noBreakSpace$),
map(([, result, text, processor, mask, noBreakSpace]) =>
processor(result, text, mask, noBreakSpace)
)
)
).subscribe(result$)

merge(
currentStep$.pipe(pluck('roll')),
pausableTimer$.pipe(
withLatestFrom(currentStep$, result$, text$, counter$),
map(([, { type, action }, result, text, counter]) => {
if (!R.isNil(counter)) {
return counter - 1
}

if (type === 'forward') {
return R.max(result.length, text.length) - 1
}

if (action === '-' && text === result) {
return 0
}

// endless loop when counter is undefined
return
})
)
).subscribe(counter$)

merge(
steps$,
reset$.pipe(
withLatestFrom(steps$),
map(R.nth(1))
),
counter$.pipe(
filter(R.equals(0)),
withLatestFrom(queue$),
map(R.nth(1)),
map(R.drop(1))
)
).subscribe(queue$)

return combineLatest(props$, result$).pipe(
map(([props, result]) => ({
...props,
result,
start,
pause,
reset,
}))
)
}),
withPropsOnChange(['start', 'reset'], props => ({
restart: () => {
props.reset()
props.start()
},
})),
lifecycle({
componentDidMount() {
const { bindMethod } = this.props

if (bindMethod) {
bindMethod({
start: this.props.start,
pause: this.props.pause,
reset: this.props.reset,
restart: this.props.restart,
})
}
},
}),
withHandlers({
onMouseEnter: props => () => {
const { onMouseEnter, mouseEnterTrigger } = props
const action = props[mouseEnterTrigger]

R.is(Function, onMouseEnter) && onMouseEnter()
R.is(Function, action) && action()
},
onMouseLeave: props => () => {
const { onMouseLeave, mouseLeaveTrigger } = props
const action = props[mouseLeaveTrigger]

R.is(Function, onMouseLeave) && onMouseLeave()
R.is(Function, action) && action()
},
})
)(({ result = '', ...otherProps }) => (
<span {...R.omit(omitProps, otherProps)}>{result}</span>
))

Scramble.displayName = 'Scramble'

Scramble.propTypes = {
autoStart: PropTypes.bool,
bindMethod: PropTypes.func,
mouseEnterTrigger: PropTypes.oneOf(['start', 'pause', 'reset', 'restart']),
mouseLeaveTrigger: PropTypes.oneOf(['start', 'pause', 'reset', 'restart']),
noBreakSpace: PropTypes.bool,
speed: PropTypes.string,
steps: PropTypes.array,
text: PropTypes.string,
}

Scramble.defaultProps = {
autoStart: false,
preScramble: false,
steps: [],
speed: 'medium',
noBreakSpace: true,
}

export default Scramble
117 changes: 117 additions & 0 deletions src/Scramble.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { getPauserStream, getPropStream } from './Scramble'
import R from 'ramda'
import { TestScheduler } from 'rxjs/testing'

const assert = (actual, expected) => {
const isEqual = R.equals(actual, expected)
if (!isEqual) {
console.error('Actual:', actual, '\n\n', 'Expected:', expected) // eslint-disable-line
}
expect(isEqual).toBe(true)
}

test('getPauserStream, auto start and queue is not empty', () => {
const testScheduler = new TestScheduler(assert)

testScheduler.run(({ cold, expectObservable, flush }) => {
const autoStartMarble = cold('T----------|', { T: true })
const isQueueEmptyMarble = cold('F----------|', { F: false })
const pauseMarble = cold('------x----|')
const resetMarble = cold('----------x|')
const startMarble = cold('--------x--|')
const resultMarble = 'F-----T-F-F|'

const source = getPauserStream(
autoStartMarble,
isQueueEmptyMarble,
pauseMarble,
resetMarble,
startMarble
)
const values = {
T: true,
F: false,
}

expectObservable(source).toBe(resultMarble, values)

flush()
})
})

test('getPauserStream, queue is not empty', () => {
const testScheduler = new TestScheduler(assert)

testScheduler.run(({ cold, expectObservable, flush }) => {
const autoStartMarble = cold('F---------|', { F: false })
const isQueueEmptyMarble = cold('F---------|', { F: false })
const pauseMarble = cold('-----x----|')
const resetMarble = cold('---------x|')
const startMarble = cold('-------x--|')
const resultMarble = 'T----T-F-T|'

const source = getPauserStream(
autoStartMarble,
isQueueEmptyMarble,
pauseMarble,
resetMarble,
startMarble
)
const values = {
T: true,
F: false,
}

expectObservable(source).toBe(resultMarble, values)

flush()
})
})

test('getPauserStream, queue is empty', () => {
const testScheduler = new TestScheduler(assert)

testScheduler.run(({ cold, expectObservable, flush }) => {
const autoStartMarble = cold('F---------|', { F: false })
const isQueueEmptyMarble = cold('T---------|', { T: true })
const pauseMarble = cold('-----x----|')
const resetMarble = cold('---------x|')
const startMarble = cold('-------x--|')
const resultMarble = '(TT)-T-T-T|'

const source = getPauserStream(
autoStartMarble,
isQueueEmptyMarble,
pauseMarble,
resetMarble,
startMarble
)
const values = {
T: true,
F: false,
}

expectObservable(source).toBe(resultMarble, values)

flush()
})
})

test('getPropStream', () => {
const testScheduler = new TestScheduler(assert)

testScheduler.run(({ cold, expectObservable, flush }) => {
const propsMarble = cold('p----p--q-|', {
p: { foo: 'bar' },
q: { foo: 'xyz' },
})
const resultMarble = 'r-------s-|'

const source = getPropStream(propsMarble, 'foo')
const values = { r: 'bar', s: 'xyz' }

expectObservable(source).toBe(resultMarble, values)

flush()
})
})
262 changes: 4 additions & 258 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,260 +1,6 @@
import * as React from 'react'
import rxjsObservableConfig from 'recompose/rxjsObservableConfig'
import setObservableConfig from 'recompose/setObservableConfig'
import createEventHandler from 'recompose/createEventHandler'
import compose from 'recompose/compose'
import setPropTypes from 'recompose/setPropTypes'
import mapPropsStream from 'recompose/mapPropsStream'
import withHandlers from 'recompose/withHandlers'
import withPropsOnChange from 'recompose/withPropsOnChange'
import lifecycle from 'recompose/lifecycle'
import Rx from 'rxjs'
import R from 'ramda'
import PropTypes from 'prop-types'
import Scramble from './Scramble'
import getNoBreakSpaces from './utils/getNoBreakSpaces'

import { scramble, descramble, mixcramble } from './utils/scramblers'
import { getRandomMask, getFullMask, getForwardMask } from './utils/getMask'
export default Scramble

setObservableConfig(rxjsObservableConfig)

const omitProps = [
'autoStart',
'bindMethod',
'pause',
'preScramble',
'reset',
'speed',
'start',
'steps',
'text',
]

const speed = {
fast: 25,
medium: 50,
slow: 100,
}

export default compose(
setPropTypes({
autoStart: PropTypes.bool,
bindMethod: PropTypes.func,
mouseEnterTrigger: PropTypes.oneOf(['start', 'pause', 'reset', 'restart']),
mouseLeaveTrigger: PropTypes.oneOf(['start', 'pause', 'reset', 'restart']),
speed: PropTypes.string,
steps: PropTypes.array,
text: PropTypes.string,
}),
mapPropsStream(props$ => {
const { handler: start, stream: start$ } = createEventHandler()
const { handler: pause, stream: pause$ } = createEventHandler()
const { handler: reset, stream: reset$ } = createEventHandler()
const queue$ = new Rx.Subject()
const counter$ = new Rx.Subject()
const result$ = new Rx.Subject()

const autoStart$ = props$
.map(R.propOr(false, 'autoStart'))
.distinctUntilChanged()

const preScramble$ = props$
.map(R.propOr(false, 'preScramble'))
.distinctUntilChanged()

const initText$ = props$
.pluck('text')
.distinctUntilChanged()
.share()

const steps$ = props$
.map(R.propOr([], 'steps'))
.distinctUntilChanged()
.share()

const period$ = props$
.map(R.propOr('medium', 'speed'))
.distinctUntilChanged()
.map(R.prop(R.__, speed))

const currentStep$ = queue$
.map(R.pathOr({}, [0]))
.share()

const text$ = currentStep$
.map(R.prop('text'))
.filter(R.is(String))
.merge(initText$)
.distinctUntilChanged()

const pauser$ = Rx.Observable.merge(
autoStart$.map(R.not),
pause$.mapTo(true),
reset$.withLatestFrom(autoStart$, (_, autoStart) => !autoStart),
start$.withLatestFrom(queue$, (_, queue) => queue.length === 0),
queue$
.map(queue => queue.length === 0)
.filter(R.identity),
)

const processor$ = currentStep$.map(({ action, type }) => {
switch(action) {
case '+':
return scramble
case '-':
return type === 'forward' ? mixcramble : descramble
default:
return R.identity
}
})

const mask$ = Rx.Observable
.combineLatest(
currentStep$,
counter$,
result$,
text$,
({ type, roll }, counter, result, text) => {
const length = R.max(result.length, text.length)

switch(type) {
case 'random':
return getRandomMask(length)
case 'forward':
return getForwardMask(length, roll, counter)
case 'all':
default:
return getFullMask(length)
}
}
)

const pausableTimer$ = pauser$
.combineLatest(period$)
.switchMap(([paused, period]) => paused
? Rx.Observable.empty()
// startWith 0 to send event immediately
: Rx.Observable.interval(period).startWith(0)
)

Rx.Observable
.merge(
initText$
.combineLatest(
preScramble$,
reset$.startWith(''),
(text, preScramble) => preScramble
? scramble(null, text, getFullMask(text.length))
: text
),
pausableTimer$
.withLatestFrom(
result$,
text$,
processor$,
mask$,
(_, result, text, processor, mask) => processor(result, text, mask),
)
)
.subscribe(result$)

Rx.Observable
.merge(
currentStep$.map(R.prop('roll')),
pausableTimer$
.withLatestFrom(
currentStep$,
result$,
text$,
counter$,
(_, { type, action }, result, text, counter) => {
if (!R.isNil(counter)) {
return counter - 1
}

if (type === 'forward') {
return R.max(result.length, text.length)
}

if (action === '-' && text === result) {
return 0
}

// endless loop when counter is undefined
return
}
),
)
.subscribe(counter$)

Rx.Observable
.merge(
steps$,
reset$.withLatestFrom(steps$, R.nthArg(1)),
counter$
.filter(R.equals(0))
.withLatestFrom(queue$, R.nthArg(1))
.map(R.drop(1)),
)
.subscribe(queue$)

return props$.combineLatest(
result$,
(props, result) => ({
...props,
result,
start,
pause,
reset,
})
)
}),
withPropsOnChange(
['start', 'reset'],
props => ({
restart: () => {
props.reset()
props.start()
},
})
),
lifecycle({
componentDidMount() {
const { bindMethod } = this.props

if (bindMethod) {
bindMethod({
start: this.props.start,
pause: this.props.pause,
reset: this.props.reset,
restart: this.props.restart,
})
}
},
}),
withHandlers({
onMouseEnter: props => () => {
const {
onMouseEnter,
mouseEnterTrigger,
} = props
const action = props[mouseEnterTrigger]

R.is(Function, onMouseEnter) && onMouseEnter()
R.is(Function, action) && action()
},
onMouseLeave: props => () => {
const {
onMouseLeave,
mouseLeaveTrigger,
} = props
const action = props[mouseLeaveTrigger]

R.is(Function, onMouseLeave) && onMouseLeave()
R.is(Function, action) && action()
},
}),
)(({ result = '', ...otherProps}) => (
<span {...R.omit(omitProps, otherProps)}>
{result}
</span>
))
export { getNoBreakSpaces }
6 changes: 6 additions & 0 deletions src/utils/constant.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import R from 'ramda'

export const NO_BREAK_SPACE = 160

export const PRINTABLE_CHAR_CODES = R.range(33, 127)
export const NO_BREAK_SPACE_CHAR_CODE = NO_BREAK_SPACE
16 changes: 10 additions & 6 deletions src/utils/getMask.js
Original file line number Diff line number Diff line change
@@ -5,17 +5,21 @@ export const getRandomMask = R.pipe(
R.map(
R.pipe(
Math.random,
Math.round,
Math.round
)
)
)

export const getFullMask = R.pipe(
R.range(0),
R.map(R.always(1)),
R.map(R.always(1))
)

export const getForwardMask = (length, roll = length, count = length) => {
export const getForwardMask = (length, roll = length, count = roll) => {
if (count <= 1) {
return R.repeat(1, length)
}

const base = R.pipe(
R.divide,
Math.floor,
@@ -28,14 +32,14 @@ export const getForwardMask = (length, roll = length, count = length) => {

return R.pipe(
R.scan(R.add, 0),
R.findLastIndex(R.gte(roll - count + 1)),
R.findIndex(R.lt(roll - count)),
R.juxt([
R.repeat(1),
R.pipe(
R.subtract(length),
R.repeat(0),
R.repeat(0)
),
]),
R.flatten,
R.flatten
)(base)
}
41 changes: 41 additions & 0 deletions src/utils/getMask.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { getFullMask, getForwardMask } from './getMask'

test('getFullMask', () => {
expect(getFullMask(5)).toEqual([1, 1, 1, 1, 1])
})

test('getForwardMask', () => {
expect(getForwardMask(5, 10, 10)).toEqual([1, 0, 0, 0, 0])
expect(getForwardMask(5, 10, 9)).toEqual([1, 0, 0, 0, 0])
expect(getForwardMask(5, 10, 8)).toEqual([1, 1, 0, 0, 0])
expect(getForwardMask(5, 10, 7)).toEqual([1, 1, 0, 0, 0])
expect(getForwardMask(5, 10, 6)).toEqual([1, 1, 1, 0, 0])
expect(getForwardMask(5, 10, 5)).toEqual([1, 1, 1, 0, 0])
expect(getForwardMask(5, 10, 4)).toEqual([1, 1, 1, 1, 0])
expect(getForwardMask(5, 10, 3)).toEqual([1, 1, 1, 1, 0])
expect(getForwardMask(5, 10, 2)).toEqual([1, 1, 1, 1, 1])
expect(getForwardMask(5, 10, 1)).toEqual([1, 1, 1, 1, 1])

expect(getForwardMask(5, 3, 3)).toEqual([1, 0, 0, 0, 0])
expect(getForwardMask(5, 3, 2)).toEqual([1, 1, 0, 0, 0])
expect(getForwardMask(5, 3, 1)).toEqual([1, 1, 1, 1, 1])

expect(getForwardMask(5, 8, 8)).toEqual([1, 0, 0, 0, 0])
expect(getForwardMask(5, 8, 7)).toEqual([1, 0, 0, 0, 0])
expect(getForwardMask(5, 8, 6)).toEqual([1, 1, 0, 0, 0])
expect(getForwardMask(5, 8, 5)).toEqual([1, 1, 0, 0, 0])
expect(getForwardMask(5, 8, 4)).toEqual([1, 1, 1, 0, 0])
expect(getForwardMask(5, 8, 3)).toEqual([1, 1, 1, 0, 0])
expect(getForwardMask(5, 8, 2)).toEqual([1, 1, 1, 1, 0])
expect(getForwardMask(5, 8, 1)).toEqual([1, 1, 1, 1, 1])

expect(getForwardMask(5)).toEqual([1, 0, 0, 0, 0])
})

const mockMath = Object.create(global.Math)
mockMath.random = () => 0.5
global.Math = mockMath

test('getRandomMask', () => {
expect(getFullMask(5)).toEqual([1, 1, 1, 1, 1])
})
8 changes: 8 additions & 0 deletions src/utils/getNoBreakSpaces.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import R from 'ramda'

import { NO_BREAK_SPACE } from './constant'

export default R.pipe(
R.repeat(String.fromCharCode(NO_BREAK_SPACE)),
R.reduce(R.concat, '')
)
75 changes: 44 additions & 31 deletions src/utils/scramblers.js
Original file line number Diff line number Diff line change
@@ -1,39 +1,52 @@
import R from 'ramda'

const START_CODE = 33
const END_CODE = 126
const RANGE = END_CODE - START_CODE
import { PRINTABLE_CHAR_CODES, NO_BREAK_SPACE_CHAR_CODE } from './constant'

const randomChar = R.pipe(
Math.random,
R.multiply(RANGE),
Math.floor,
R.add(START_CODE),
String.fromCharCode,
)
const noBreakSpace = String.fromCharCode(NO_BREAK_SPACE_CHAR_CODE)

const randomChar = () =>
R.pipe(
R.concat([NO_BREAK_SPACE_CHAR_CODE]),
array => array[Math.floor(Math.random() * array.length)],
String.fromCharCode
)(PRINTABLE_CHAR_CODES)

const array2String = R.reduce(R.concat, '')

export const mixcramble = (_, text = "", mask = []) => R.pipe(
R.addIndex(R.map)((d, i) => d === 0
? randomChar()
: text[i] || ''
),
array2String,
)(mask)
export const mixcramble = (_, text = '', mask = [], noBreakSpaceFlag) =>
R.pipe(
R.addIndex(R.map)(
(d, i) =>
d === 0
? randomChar()
: text[i] || (noBreakSpaceFlag ? noBreakSpace : '')
),
array2String
)(mask)

export const descramble = (result = "", text = "", mask = []) => R.pipe(
R.addIndex(R.map)((d, i) => d === 0
? result[i] || ''
: text[i] || ''
),
array2String,
)(mask)
export const descramble = (
result = '',
text = '',
mask = [],
noBreakSpaceFlag
) =>
R.pipe(
R.addIndex(R.map)(
(d, i) =>
d === 0
? result[i] || (noBreakSpaceFlag ? noBreakSpace : '')
: text[i] || (noBreakSpaceFlag ? noBreakSpace : '')
),
array2String
)(mask)

export const scramble = (result = "", _, mask = []) => R.pipe(
R.addIndex(R.map)((d, i) => d === 0
? result[i] || ''
: randomChar()
),
array2String,
)(mask)
export const scramble = (result = '', _, mask = [], noBreakSpaceFlag) =>
R.pipe(
R.addIndex(R.map)(
(d, i) =>
d === 0
? result[i] || (noBreakSpaceFlag ? noBreakSpace : '')
: randomChar()
),
array2String
)(mask)
2,786 changes: 2,715 additions & 71 deletions yarn.lock

Large diffs are not rendered by default.