mirror of
https://github.com/ianstormtaylor/slate.git
synced 2025-09-07 05:30:41 +02:00
first commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
test/support/build.js
|
2
.npmignore
Normal file
2
.npmignore
Normal file
@@ -0,0 +1,2 @@
|
||||
support
|
||||
test
|
6
.travis.yml
Normal file
6
.travis.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
language: node_js
|
||||
script: make test
|
||||
node_js:
|
||||
- "stable"
|
||||
- "4"
|
||||
- "6"
|
5
History.md
Normal file
5
History.md
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
0.0.1
|
||||
-----
|
||||
- :sparkles:
|
||||
|
58
Makefile
Normal file
58
Makefile
Normal file
@@ -0,0 +1,58 @@
|
||||
|
||||
# Binaries.
|
||||
bin = ./node_modules/.bin
|
||||
browserify = $(bin)/browserify
|
||||
standard = $(bin)/standard
|
||||
mocha = $(bin)/mocha
|
||||
mocha-phantomjs = $(bin)/mocha-phantomjs
|
||||
node = node
|
||||
watchify = $(bin)/watchify
|
||||
|
||||
# Flags.
|
||||
DEBUG ?=
|
||||
|
||||
# Config.
|
||||
ifeq ($(DEBUG),true)
|
||||
mocha += debug
|
||||
node += debug
|
||||
endif
|
||||
|
||||
# Remove the generated files.
|
||||
clean:
|
||||
@ rm -rf ./node_modules
|
||||
|
||||
# Build the examples.
|
||||
examples: ./node_modules
|
||||
@ $(browserify) --debug --transform babelify --outfile ./examples/basic/build.js ./examples/basic/index.js
|
||||
|
||||
# Lint the sources files with Standard JS.
|
||||
lint: ./node_modules
|
||||
@ $(standard) ./lib
|
||||
|
||||
# Install the dependencies.
|
||||
node_modules: ./package.json
|
||||
@ npm install
|
||||
@ touch ./package.json
|
||||
|
||||
# Build the test source.
|
||||
test/support/build.js: ./node_modules $(shell find ./lib) ./test/browser.js
|
||||
@ $(browserify) --transform babelify --outfile ./test/support/build.js ./test/browser.js
|
||||
|
||||
# Run the tests.
|
||||
test: test-browser test-server
|
||||
|
||||
# Run the browser-side tests.
|
||||
test-browser: ./node_modules ./test/support/build.js
|
||||
@ $(mocha-phantomjs) --reporter spec --timeout 5000 ./test/support/browser.html
|
||||
|
||||
# Run the server-side tests.
|
||||
test-server: ./node_modules
|
||||
@ $(mocha) --reporter spec --timeout 5000 ./test/server.js
|
||||
|
||||
# Watch the examples.
|
||||
watch-examples: ./node_modules
|
||||
@ $(MAKE) examples browserify=$(watchify)
|
||||
|
||||
# Phony targets.
|
||||
.PHONY: examples
|
||||
.PHONY: test
|
322
Readme.md
Normal file
322
Readme.md
Normal file
@@ -0,0 +1,322 @@
|
||||
|
||||
# Metascraper [](https://travis-ci.org/ianstormtaylor/metascraper)
|
||||
|
||||
A library to easily scrape metadata from an article on the web using Open Graph metadata, regular HTML metadata, and series of fallbacks. Following a few principles:
|
||||
|
||||
- Have a high accuracy for online articles by default.
|
||||
- Be usable on the server and in the browser.
|
||||
- Make it simple to add new rules or override existing ones.
|
||||
- Don't restrict rules to CSS selectors or text accessors.
|
||||
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Example](#example)
|
||||
- [Metadata](#metadata)
|
||||
- [Comparison](#comparison)
|
||||
- [Installation](#installation)
|
||||
- [Server-side Usage](#server-side-usage)
|
||||
- [Browser-side Usage](#browser-side-usage)
|
||||
- [Creating & Overriding Rules](#creating-overriding-rules)
|
||||
- [API](#api)
|
||||
- [License](#license)
|
||||
|
||||
|
||||
## Example
|
||||
|
||||
Using **Metascraper**, this metadata...
|
||||
|
||||
{
|
||||
"author": "Ellen Huet",
|
||||
"date": "2016-05-24T18:00:03.894Z",
|
||||
"description": "The HR startups go to war.",
|
||||
"image": "https://assets.bwbx.io/images/users/iqjWHBFdfxIU/ioh_yWEn8gHo/v1/-1x-1.jpg",
|
||||
"publisher": "Bloomberg.com",
|
||||
"title": "As Zenefits Stumbles, Gusto Goes Head-On by Selling Insurance",
|
||||
"url": "http://www.bloomberg.com/news/articles/2016-05-24/as-zenefits-stumbles-gusto-goes-head-on-by-selling-insurance"
|
||||
}
|
||||
|
||||
...would be scraped from this article...
|
||||
|
||||
[](http://www.bloomberg.com/news/articles/2016-05-24/as-zenefits-stumbles-gusto-goes-head-on-by-selling-insurance)
|
||||
|
||||
|
||||
## Metadata
|
||||
|
||||
Here is a list of the metadata that **Metascraper** collects by default:
|
||||
|
||||
- **`author`** — eg. `Noah Kulwin`<br/>
|
||||
A human-readable representation of the author's name.
|
||||
|
||||
- **`date`** — eg. `2016-05-27T00:00:00.000Z`<br/>
|
||||
An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) representation of the date the article was published.
|
||||
|
||||
- **`description`** — eg. `Venture capitalists are raising money at the fastest rate...`<br/>
|
||||
The publisher's chosen description of the article.
|
||||
|
||||
- **`image`** — eg. `https://assets.entrepreneur.com/content/3x2/1300/20160504155601-GettyImages-174457162.jpeg`<br/>
|
||||
An image URL that best represents the article.
|
||||
|
||||
- **`publisher`** — eg. `Fast Company`<br/>
|
||||
A human-readable representation of the publisher's name.
|
||||
|
||||
- **`title`** — eg. `Meet Wall Street's New A.I. Sheriffs`<br/>
|
||||
The publisher's chosen title of the article.
|
||||
|
||||
- **`url`** — eg. `http://motherboard.vice.com/read/google-wins-trial-against-oracle-saves-9-billion`<br/>
|
||||
The URL of the article.
|
||||
|
||||
|
||||
## Comparison
|
||||
|
||||
To give you an idea of how accurate **Metascraper** is, here is a comparison of similar libraries:
|
||||
|
||||
| Library | [`metascraper`](https://www.npmjs.com/package/metascraper) | [`html-metadata`](https://www.npmjs.com/package/html-metadata) | [`node-metainspector`](https://www.npmjs.com/package/node-metainspector) | [`open-graph-scraper`](https://www.npmjs.com/package/open-graph-scraper) | [`unfluff`](https://www.npmjs.com/package/unfluff) |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| Correct | **95.54%** | **74.56%** | **61.16%** | **66.52%** | **70.90%** |
|
||||
| Incorrect | 1.79% | 1.79% | 0.89% | 6.70% | 10.27% |
|
||||
| Missed | 2.68% | 23.67% | 37.95% | 26.34% | 8.95% |
|
||||
|
||||
A big part of the reason for **Metascraper**'s higher accuracy is that it relies on a series of fallbacks for each piece of metadata, instead of just looking for the most commonly-used, spec-compliant pieces of metadata, like Open Graph. **Metascraper**'s default settings are targetted specifically at parsing online articles, which is why it's able to be more highly-tuned than the other libraries for that purpose.
|
||||
|
||||
If you're interested in the breakdown by individual pieces of metadata, check out the [full comparison summary](/support/comparison), or dive into the [raw result data for each library](/support/comparison/results).
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Simply install with [`npm`](https://www.npmjs.com/):
|
||||
|
||||
```
|
||||
npm install metascraper
|
||||
```
|
||||
|
||||
|
||||
## Server-side Usage
|
||||
|
||||
On the server, you're typically going to only have a `url` to scrape, or already have the `html` downloaded. Here's what a simple use case might look like:
|
||||
|
||||
```js
|
||||
import Metascraper from 'metascraper'
|
||||
|
||||
Metascraper
|
||||
.scrapeUrl('http://www.bloomberg.com/news/articles/2016-05-24/as-zenefits-stumbles-gusto-goes-head-on-by-selling-insurance')
|
||||
.then((metadata) => {
|
||||
console.log(metadata)
|
||||
})
|
||||
|
||||
// {
|
||||
// "author": "Ellen Huet",
|
||||
// "date": "2016-05-24T18:00:03.894Z",
|
||||
// "description": "The HR startups go to war.",
|
||||
// "image": "https://assets.bwbx.io/images/users/iqjWHBFdfxIU/ioh_yWEn8gHo/v1/-1x-1.jpg",
|
||||
// "publisher": "Bloomberg.com",
|
||||
// "title": "As Zenefits Stumbles, Gusto Goes Head-On by Selling Insurance"
|
||||
// }
|
||||
```
|
||||
|
||||
Or, if you are using `async/await`, you can simply do:
|
||||
|
||||
```js
|
||||
const metadata = await Metascraper.scrapeUrl('http://www.bloomberg.com/news/articles/2016-05-24/as-zenefits-stumbles-gusto-goes-head-on-by-selling-insurance')
|
||||
```
|
||||
|
||||
|
||||
Similarly, if you already have the `html` downloaded, you can use the `scrapeHtml` method instead:
|
||||
|
||||
```js
|
||||
const metadata = await Metascraper.scrapeHtml(html)
|
||||
```
|
||||
|
||||
That's it! If you want to customize what exactly gets scraped, check out the documention on [the rules system](#rules).
|
||||
|
||||
|
||||
## Browser-side Usage
|
||||
|
||||
In the browser, for example inside of a Chrome extension, you might already have access to the `window` of the document you'd like to scrape. You can simply use the `scrapeWindow` method to get the metadata:
|
||||
|
||||
```js
|
||||
import Metascraper from 'metascraper'
|
||||
|
||||
Metascraper
|
||||
.scrapeWindow(window)
|
||||
.then((metadata) => {
|
||||
console.log(metadata)
|
||||
})
|
||||
|
||||
// {
|
||||
// "author": "Ellen Huet",
|
||||
// "date": "2016-05-24T18:00:03.894Z",
|
||||
// "description": "The HR startups go to war.",
|
||||
// "image": "https://assets.bwbx.io/images/users/iqjWHBFdfxIU/ioh_yWEn8gHo/v1/-1x-1.jpg",
|
||||
// "publisher": "Bloomberg.com",
|
||||
// "title": "As Zenefits Stumbles, Gusto Goes Head-On by Selling Insurance"
|
||||
// }
|
||||
```
|
||||
|
||||
Or if you are using `async/await` it might look even simpler:
|
||||
|
||||
```js
|
||||
const metadata = await Metascraper.scrapeWindow(window)
|
||||
```
|
||||
|
||||
Of course, you can also still scrape directly from `html` or a `url` if you choose to.
|
||||
|
||||
|
||||
## Creating & Overiding Rules
|
||||
|
||||
By default, Metascraper ships with a set of rules that are tuned to parse out information from online articles—blogs, newspapers, press releases, etc. But you don't have to use the default rules. If you have a different use case, supplying your own rules is easy to do.
|
||||
|
||||
Each rule is simply a function that receives a [Cheerio](https://github.com/cheeriojs/cheerio) instance of the document, and that returns the value it has scraped. (Or a `Promise` in the case of asynchronous scraping.) Like so:
|
||||
|
||||
```js
|
||||
function myTitleRule($) {
|
||||
const text = $('h1').text()
|
||||
return text
|
||||
}
|
||||
```
|
||||
|
||||
All of the rules are then packaged up into a single dictionary, which has the same shape as the metadata that will be scraped. Like so:
|
||||
|
||||
```js
|
||||
const MY_RULES = {
|
||||
title: myTitleRule,
|
||||
summary: mySummaryRule,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
And then you can pass that rules dictionary into any of the scraping functions as the second argument, like so:
|
||||
|
||||
```js
|
||||
const metadata = Metascraper.scrapeHtml(html, MY_RULES)
|
||||
```
|
||||
|
||||
Not only that, but instead of being just a function, rules can be passed as an array of fallbacks, in case the earlier functions in the array don't return results. Like so:
|
||||
|
||||
```js
|
||||
const MY_RULES = {
|
||||
title: [
|
||||
myPreferredTitleRule,
|
||||
myFallbackTitleRule,
|
||||
mySuperLastResortTitleRule,
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The beauty of the system is that it means simple scraping needs can be defined inline easily, like so:
|
||||
|
||||
```js
|
||||
const rules = {
|
||||
title: $ => $('title').text(),
|
||||
date: $ => $('time[pubdate]').attr('datetime'),
|
||||
excerpt: $ => $('p').first().text(),
|
||||
}
|
||||
|
||||
const metadata = Metascraper.scrapeHtml(html, rules)
|
||||
```
|
||||
|
||||
But in more complex cases, the set of rules can be packaged separately, and even shared with others, for example:
|
||||
|
||||
```js
|
||||
import Metascraper from 'metascraper'
|
||||
import RECIPE_RULES from 'metascraper-recipes'
|
||||
|
||||
const metadata = Metascraper.scrapeHtml(html, RECIPE_RULES)
|
||||
```
|
||||
|
||||
And if you want to use the default rules, but with a few tweaks of your own, it's as simple as extending the object:
|
||||
|
||||
```js
|
||||
import Metascraper from 'metascraper'
|
||||
|
||||
const NEW_RULES = {
|
||||
...Metascraper.RULES,
|
||||
summary: mySummaryRule,
|
||||
title: [
|
||||
myPreferredTitleRule,
|
||||
myFallbackTitleRule,
|
||||
mySuperLastResortTitleRule,
|
||||
]
|
||||
}
|
||||
|
||||
const metadata = Metascraper.scrapeHtml(html, NEW_RULES)
|
||||
```
|
||||
|
||||
For a more complex example of how rules work, [check out the default rules](/lib/rules).
|
||||
|
||||
|
||||
## API
|
||||
|
||||
#### `Metascraper.scrapeUrl(url, [rules])`
|
||||
|
||||
```js
|
||||
import Metascraper from 'metascraper'
|
||||
|
||||
Metascraper
|
||||
.scrapeUrl(url)
|
||||
.then((metadata) => {
|
||||
// ...
|
||||
})
|
||||
```
|
||||
```js
|
||||
import Metascraper from 'metascraper'
|
||||
|
||||
const metadata = await Metascraper.scrapeUrl(url)
|
||||
```
|
||||
|
||||
Scrapes a `url` with an optional set of `rules`.
|
||||
|
||||
#### `Metascraper.scrapeHtml(html, [rules])`
|
||||
|
||||
```js
|
||||
import Metascraper from 'metascraper'
|
||||
|
||||
Metascraper
|
||||
.scrapeHtml(html)
|
||||
.then((metadata) => {
|
||||
// ...
|
||||
})
|
||||
```
|
||||
```js
|
||||
import Metascraper from 'metascraper'
|
||||
|
||||
const metadata = await Metascraper.scrapeHtml(html)
|
||||
```
|
||||
|
||||
Scrapes an `html` string with an optional set of `rules`.
|
||||
|
||||
#### `Metascraper.scrapeWindow(window, [rules])`
|
||||
|
||||
```js
|
||||
import Metascraper from 'metascraper'
|
||||
|
||||
Metascraper
|
||||
.scrapeWindow(window)
|
||||
.then((metadata) => {
|
||||
// ...
|
||||
})
|
||||
```
|
||||
```js
|
||||
import Metascraper from 'metascraper'
|
||||
|
||||
const metadata = await Metascraper.scrapeWindow(window)
|
||||
```
|
||||
|
||||
Scrapes a `window` object with an optional set of `rules`.
|
||||
|
||||
#### `Metascraper.RULES`
|
||||
|
||||
A dictionary of the default rules, in case you want to extend them.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright © 2016, Ian Storm Taylor
|
||||
|
||||
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.
|
29437
examples/basic/build.js
Normal file
29437
examples/basic/build.js
Normal file
File diff suppressed because one or more lines are too long
10
examples/basic/index.html
Normal file
10
examples/basic/index.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Editor | Basic Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<main></main>
|
||||
<script src="build.js"></script>
|
||||
</body>
|
||||
</html>
|
143
examples/basic/index.js
Normal file
143
examples/basic/index.js
Normal file
@@ -0,0 +1,143 @@
|
||||
|
||||
import Editor, { State } from '../..'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
|
||||
/**
|
||||
* State.
|
||||
*/
|
||||
|
||||
const state = {
|
||||
nodes: [
|
||||
{
|
||||
key: '1',
|
||||
kind: 'node',
|
||||
type: 'code',
|
||||
data: {},
|
||||
children: [
|
||||
{
|
||||
key: '2',
|
||||
type: 'text',
|
||||
ranges: [
|
||||
{
|
||||
text: 'A\nfew\nlines\nof\ncode.',
|
||||
marks: []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
kind: 'node',
|
||||
type: 'paragraph',
|
||||
data: {},
|
||||
children: [
|
||||
{
|
||||
key: '4',
|
||||
type: 'text',
|
||||
ranges: [
|
||||
{
|
||||
text: 'A ',
|
||||
marks: []
|
||||
},
|
||||
{
|
||||
text: 'simple',
|
||||
marks: ['bold']
|
||||
},
|
||||
{
|
||||
text: ' paragraph of text.',
|
||||
marks: []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
selection: {
|
||||
anchorKey: '3.4',
|
||||
anchorOffset: 9,
|
||||
focusKey: '3.4',
|
||||
focusOffset: 18
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderers.
|
||||
*/
|
||||
|
||||
function renderNode(node) {
|
||||
switch (node.type) {
|
||||
case 'code': {
|
||||
return (props) => {
|
||||
return (
|
||||
<pre>
|
||||
<code>
|
||||
{props.children}
|
||||
</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
}
|
||||
case 'paragraph': {
|
||||
return (props) => {
|
||||
return (
|
||||
<p>
|
||||
{props.children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
}
|
||||
default: {
|
||||
debugger
|
||||
throw new Error(`Unknown node type "${node.type}".`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderMark(mark) {
|
||||
switch (mark) {
|
||||
case 'bold': {
|
||||
return {
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown mark type "${mark}".`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App.
|
||||
*/
|
||||
|
||||
class App extends React.Component {
|
||||
|
||||
state = {
|
||||
state: State.create(state)
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Editor
|
||||
renderNode={renderNode}
|
||||
renderMark={renderMark}
|
||||
state={this.state.state}
|
||||
onChange={(state) => {
|
||||
console.log('State:', state)
|
||||
this.setState({ state })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach.
|
||||
*/
|
||||
|
||||
const app = <App />
|
||||
const root = document.body.querySelector('main')
|
||||
ReactDOM.render(app, root)
|
89
lib/components/content.js
Normal file
89
lib/components/content.js
Normal file
@@ -0,0 +1,89 @@
|
||||
|
||||
import React from 'react'
|
||||
import TextNode from './text-node'
|
||||
import TextNodeModel from '../models/text-node'
|
||||
import keycode from 'keycode'
|
||||
|
||||
/**
|
||||
* Content.
|
||||
*/
|
||||
|
||||
class Content extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
onChange: React.PropTypes.func,
|
||||
onKeyDown: React.PropTypes.func,
|
||||
renderMark: React.PropTypes.func.isRequired,
|
||||
renderNode: React.PropTypes.func.isRequired,
|
||||
state: React.PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
onChange(state) {
|
||||
this.props.onChange(state)
|
||||
}
|
||||
|
||||
onKeyDown(e) {
|
||||
const key = keycode(e.which)
|
||||
|
||||
// COMPAT: Certain keys should never be handled by the browser's mechanism,
|
||||
// because using the native contenteditable behavior introduces quirks.
|
||||
if (key === 'escape' || key === 'return') {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
this.props.onKeyDown(e)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { state } = this.props
|
||||
const { nodes, selection } = state
|
||||
const children = nodes
|
||||
.toArray()
|
||||
.map(node => this.renderNode(node))
|
||||
|
||||
return (
|
||||
<div
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
data-type='content'
|
||||
onKeyDown={(e) => this.onKeyDown(e)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderNode(node) {
|
||||
const { renderMark, renderNode } = this.props
|
||||
|
||||
if (node instanceof TextNodeModel) {
|
||||
return (
|
||||
<TextNode
|
||||
key={node.key}
|
||||
node={node}
|
||||
renderMark={renderMark}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const Component = renderNode(node)
|
||||
const children = node.children
|
||||
.toArray()
|
||||
.map(child => this.renderNode(child))
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...node}
|
||||
key={node.key}
|
||||
children={children}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default Content
|
107
lib/components/editor.js
Normal file
107
lib/components/editor.js
Normal file
@@ -0,0 +1,107 @@
|
||||
|
||||
import Content from './content'
|
||||
import React from 'react'
|
||||
import CORE_PLUGIN from '../plugins/core'
|
||||
|
||||
/**
|
||||
* Editor.
|
||||
*/
|
||||
|
||||
class Editor extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
plugins: React.PropTypes.array,
|
||||
renderMark: React.PropTypes.func,
|
||||
renderNode: React.PropTypes.func,
|
||||
state: React.PropTypes.object,
|
||||
onChange: React.PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
plugins: [],
|
||||
state: {}
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
plugins: this.resolvePlugins(props),
|
||||
state: props.state
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUpdate(props) {
|
||||
const plugins = this.resolvePlugins(props)
|
||||
this.setState({ plugins })
|
||||
}
|
||||
|
||||
onChange(state) {
|
||||
for (const plugin of this.state.plugins) {
|
||||
if (!plugin.onChange) continue
|
||||
const newState = plugin.onChange(state, this)
|
||||
if (newState == null) continue
|
||||
state = newState
|
||||
}
|
||||
|
||||
this.props.onChange(state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the editor's current `state`.
|
||||
*
|
||||
* @return {State} state
|
||||
*/
|
||||
|
||||
getState() {
|
||||
return this.state.state
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the `keydown` event.
|
||||
*
|
||||
* @param {Event} e
|
||||
*/
|
||||
|
||||
onKeyDown(e) {
|
||||
for (const plugin of this.state.plugins) {
|
||||
if (plugin.onKeyDown) {
|
||||
const newState = plugin.onKeyDown(e, this)
|
||||
if (newState == null) continue
|
||||
this.props.onChange(newState)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the editor.
|
||||
*
|
||||
* @return {Component} component
|
||||
*/
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Content
|
||||
renderMark={this.props.renderMark}
|
||||
renderNode={this.props.renderNode}
|
||||
state={this.props.state}
|
||||
onChange={state => this.onChange(state)}
|
||||
onKeyDown={e => this.onKeyDown(e)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
resolvePlugins(props) {
|
||||
return [
|
||||
...props.plugins,
|
||||
CORE_PLUGIN
|
||||
]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default Editor
|
28
lib/components/leaf-node.js
Normal file
28
lib/components/leaf-node.js
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* LeafNode.
|
||||
*/
|
||||
|
||||
class LeafNode extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
styles: React.PropTypes.object.isRequired,
|
||||
text: React.PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { text, styles } = this.props
|
||||
return (
|
||||
<span style={styles} data-type='leaf'>{text}</span>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default LeafNode
|
132
lib/components/text-node.js
Normal file
132
lib/components/text-node.js
Normal file
@@ -0,0 +1,132 @@
|
||||
|
||||
import LeafNode from './leaf-node'
|
||||
import React from 'react'
|
||||
import xor from 'lodash/xor'
|
||||
|
||||
/**
|
||||
* TextNode.
|
||||
*/
|
||||
|
||||
class TextNode extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
node: React.PropTypes.object.isRequired,
|
||||
renderMark: React.PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { node, renderMark } = this.props
|
||||
const { characters } = node
|
||||
const ranges = characters
|
||||
.toArray()
|
||||
.reduce((ranges, char, i) => {
|
||||
const previous = characters[i - 1]
|
||||
const { text } = char
|
||||
const marks = char.marks.toArray().map(mark => mark.type)
|
||||
|
||||
if (previous) {
|
||||
const previousMarks = previous.marks.toArray().map(mark => mark.type)
|
||||
const diff = xor(marks, previousMarks)
|
||||
if (!diff.length) {
|
||||
const previousRange = ranges[ranges.length - 1]
|
||||
previousRange.text += text
|
||||
return ranges
|
||||
}
|
||||
}
|
||||
|
||||
const offset = ranges.map(range => range.text).join('').length
|
||||
ranges.push({ text, marks, offset })
|
||||
return ranges
|
||||
}, [])
|
||||
|
||||
const leaves = ranges.map((range) => {
|
||||
const key = `${node.key}.${range.offset}-${range.text.length}`
|
||||
const styles = range.marks.reduce((styles, mark) => {
|
||||
return {
|
||||
...styles,
|
||||
...renderMark(mark),
|
||||
}
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<LeafNode
|
||||
key={key}
|
||||
styles={styles}
|
||||
text={range.text}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<span key={node.key} data-type='text'>{leaves}</span>
|
||||
)
|
||||
}
|
||||
|
||||
// render() {
|
||||
// const { node, renderMark } = this.props
|
||||
// const { text, marks } = node
|
||||
// const length = text.length
|
||||
// const leaves = []
|
||||
// let index = 0
|
||||
// let previousIndex = index
|
||||
|
||||
// while (index < length) {
|
||||
// const currentMarks = findMarks(marks, index)
|
||||
// const nextMarks = findMarks(marks, index + 1)
|
||||
// const changes = xor(currentMarks, nextMarks)
|
||||
|
||||
// if (!changes.length && index != length - 1) {
|
||||
// index++
|
||||
// continue
|
||||
// }
|
||||
|
||||
// const key = `${node.key}.${previousIndex}-${index}`
|
||||
// const string = text.slice(previousIndex, index)
|
||||
// const styles = currentMarks.reduce((styles, mark) => {
|
||||
// return {
|
||||
// ...styles,
|
||||
// ...renderMark(mark),
|
||||
// }
|
||||
// }, {})
|
||||
|
||||
// const leaf = (
|
||||
// <LeafNode
|
||||
// key={key}
|
||||
// styles={styles}
|
||||
// text={string}
|
||||
// />
|
||||
// )
|
||||
|
||||
// leaves.push(leaf)
|
||||
// previousIndex = index
|
||||
// index++
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <span key={node.key} data-type='text'>{leaves}</span>
|
||||
// )
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Find matching `marks` at `index`.
|
||||
*
|
||||
* @param {Array} marks
|
||||
* @param {Number} index
|
||||
* @return {Array} marks
|
||||
*/
|
||||
|
||||
function findMarks(marks, index) {
|
||||
return marks
|
||||
.filter(mark => mark.start < index)
|
||||
.filter(mark => mark.end + 1 > index)
|
||||
.map(mark => mark.type)
|
||||
.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default TextNode
|
18
lib/index.js
Normal file
18
lib/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
import Editor from './components/editor'
|
||||
import Node from './models/node'
|
||||
import NodeMap from './models/node-map'
|
||||
import Selection from './models/selection'
|
||||
import State from './models/state'
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default Editor
|
||||
export {
|
||||
Node,
|
||||
NodeMap,
|
||||
Selection,
|
||||
State
|
||||
}
|
22
lib/models/character-list.js
Normal file
22
lib/models/character-list.js
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
import Character from './character'
|
||||
import { List } from 'immutable'
|
||||
|
||||
/**
|
||||
* Character list.
|
||||
*/
|
||||
|
||||
class CharacterList extends List {
|
||||
|
||||
static create(attrs = []) {
|
||||
attrs = attrs.map(character => Character.create(character))
|
||||
return new CharacterList(attrs)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default CharacterList
|
33
lib/models/character.js
Normal file
33
lib/models/character.js
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
import MarkList from './mark-list'
|
||||
import { Record } from 'immutable'
|
||||
|
||||
/**
|
||||
* Record.
|
||||
*/
|
||||
|
||||
const CharacterRecord = new Record({
|
||||
text: '',
|
||||
marks: new MarkList()
|
||||
})
|
||||
|
||||
/**
|
||||
* Character.
|
||||
*/
|
||||
|
||||
class Character extends CharacterRecord {
|
||||
|
||||
static create(attrs) {
|
||||
return new Character({
|
||||
text: attrs.text,
|
||||
marks: MarkList.create(attrs.marks)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default Character
|
22
lib/models/mark-list.js
Normal file
22
lib/models/mark-list.js
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
import Mark from './mark'
|
||||
import { List } from 'immutable'
|
||||
|
||||
/**
|
||||
* Mark list.
|
||||
*/
|
||||
|
||||
class MarkList extends List {
|
||||
|
||||
static create(attrs = []) {
|
||||
attrs = attrs.map(mark => Mark.create(mark))
|
||||
return new MarkList(attrs)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default MarkList
|
29
lib/models/mark.js
Normal file
29
lib/models/mark.js
Normal file
@@ -0,0 +1,29 @@
|
||||
|
||||
import { Map, Record } from 'immutable'
|
||||
|
||||
/**
|
||||
* Record.
|
||||
*/
|
||||
|
||||
const MarkRecord = new Record({
|
||||
type: null,
|
||||
})
|
||||
|
||||
/**
|
||||
* Mark.
|
||||
*/
|
||||
|
||||
class Mark extends MarkRecord {
|
||||
|
||||
static create(attrs) {
|
||||
if (typeof attrs == 'string') attrs = { type: attrs }
|
||||
return new Mark(attrs)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default Mark
|
31
lib/models/node-map.js
Normal file
31
lib/models/node-map.js
Normal file
@@ -0,0 +1,31 @@
|
||||
|
||||
import Node from './node'
|
||||
import TextNode from './text-node'
|
||||
import { OrderedMap } from 'immutable'
|
||||
|
||||
/**
|
||||
* Node map.
|
||||
*/
|
||||
|
||||
class NodeMap extends OrderedMap {
|
||||
|
||||
static create(attrs) {
|
||||
if (attrs instanceof Array) {
|
||||
attrs = attrs.reduce((map, node) => {
|
||||
map[node.key] = node.type == 'text'
|
||||
? TextNode.create(node)
|
||||
: Node.create(node)
|
||||
return map
|
||||
}, {})
|
||||
}
|
||||
|
||||
return new NodeMap(attrs)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default NodeMap
|
37
lib/models/node.js
Normal file
37
lib/models/node.js
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
import NodeMap from './node-map'
|
||||
import { Map, Record } from 'immutable'
|
||||
|
||||
/**
|
||||
* Record.
|
||||
*/
|
||||
|
||||
const NodeRecord = new Record({
|
||||
key: null,
|
||||
type: null,
|
||||
data: new Map(),
|
||||
children: new NodeMap(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Node.
|
||||
*/
|
||||
|
||||
class Node extends NodeRecord {
|
||||
|
||||
static create(attrs) {
|
||||
return new Node({
|
||||
key: attrs.key,
|
||||
type: attrs.type,
|
||||
data: new Map(attrs.data),
|
||||
children: NodeMap.create(attrs.children)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default Node
|
92
lib/models/selection.js
Normal file
92
lib/models/selection.js
Normal file
@@ -0,0 +1,92 @@
|
||||
|
||||
import { Record } from 'immutable'
|
||||
|
||||
/**
|
||||
* Record.
|
||||
*/
|
||||
|
||||
const SelectionRecord = new Record({
|
||||
anchorKey: null,
|
||||
anchorOffset: 0,
|
||||
focusKey: null,
|
||||
focusOffset: 0,
|
||||
isBackward: false,
|
||||
hasFocus: false
|
||||
})
|
||||
|
||||
/**
|
||||
* Selection.
|
||||
*/
|
||||
|
||||
class Selection extends SelectionRecord {
|
||||
|
||||
static create(attrs) {
|
||||
return new Selection(attrs)
|
||||
}
|
||||
|
||||
get isCollapsed() {
|
||||
return (
|
||||
this.anchorKey === this.focusKey &&
|
||||
this.anchorOffset === this.focusOffset
|
||||
)
|
||||
}
|
||||
|
||||
get startKey() {
|
||||
return this.isBackward
|
||||
? this.focusKey
|
||||
: this.anchorKey
|
||||
}
|
||||
|
||||
get startOffset() {
|
||||
return this.isBackward
|
||||
? this.focusOffset
|
||||
: this.anchorOffset
|
||||
}
|
||||
|
||||
get endKey() {
|
||||
return this.isBackward
|
||||
? this.anchorKey
|
||||
: this.focusKey
|
||||
}
|
||||
|
||||
get endOffset() {
|
||||
return this.isBackward
|
||||
? this.anchorOffset
|
||||
: this.focusOffset
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the selection is at the start of a `state`.
|
||||
*
|
||||
* @param {State} state
|
||||
* @return {Boolean} isAtStart
|
||||
*/
|
||||
|
||||
isAtStartOf(state) {
|
||||
const { nodes } = state
|
||||
const { startKey } = this
|
||||
const first = nodes.first()
|
||||
return startKey == first.key
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the selection is at the end of a `state`.
|
||||
*
|
||||
* @param {State} state
|
||||
* @return {Boolean} isAtEnd
|
||||
*/
|
||||
|
||||
isAtEndOf(state) {
|
||||
const { nodes } = state
|
||||
const { endKey } = this
|
||||
const last = nodes.last()
|
||||
return endKey == last.key
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default Selection
|
118
lib/models/state.js
Normal file
118
lib/models/state.js
Normal file
@@ -0,0 +1,118 @@
|
||||
|
||||
import Selection from './selection'
|
||||
import NodeMap from './node-map'
|
||||
import toCamel from 'to-camel-case'
|
||||
import { Record } from 'immutable'
|
||||
|
||||
/**
|
||||
* Record.
|
||||
*/
|
||||
|
||||
const StateRecord = new Record({
|
||||
nodes: new NodeMap(),
|
||||
selection: new Selection()
|
||||
})
|
||||
|
||||
/**
|
||||
* State.
|
||||
*/
|
||||
|
||||
class State extends StateRecord {
|
||||
|
||||
/**
|
||||
* Create a new `state` from `attrs`.
|
||||
*
|
||||
* @return {State} state
|
||||
*/
|
||||
|
||||
static create(attrs) {
|
||||
return new State({
|
||||
nodes: NodeMap.create(attrs.nodes),
|
||||
selection: Selection.create(attrs.selection)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single character.
|
||||
*
|
||||
* @param {Selection} selection (optional)
|
||||
* @return {State} state
|
||||
*/
|
||||
|
||||
delete(selection = this.selection) {
|
||||
// when not collapsed, remove the entire selection
|
||||
if (!selection.isCollapsed) return this.removeSelection(selection)
|
||||
|
||||
// when already at the end of the content, there's nothing to do
|
||||
if (selection.isAtEndOf(this)) return this
|
||||
|
||||
// otherwise, remove one character ahead of the cursor
|
||||
let { startKey, startOffset } = selection
|
||||
let { nodes } = this
|
||||
let node = nodes.get(startKey)
|
||||
let endOffset = startOffset + 1
|
||||
return this.removeText(node, startOffset, endOffset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the existing selection's content.
|
||||
*
|
||||
* @param {Selection} selection (optional)
|
||||
* @return {State} state
|
||||
*/
|
||||
|
||||
removeSelection(selection = this.selection) {
|
||||
// if already collapsed, there's nothing to remove
|
||||
if (selection.isCollapsed) return this
|
||||
|
||||
// if the start and end nodes are the same, just remove the matching text
|
||||
let { nodes } = this
|
||||
let { startKey, startOffset, endKey, endOffset } = selection
|
||||
let startNode = nodes.get(startKey)
|
||||
let endNode = nodes.get(endKey)
|
||||
if (startNode == endNode) return this.removeText(startNode, startOffset, endOffset)
|
||||
|
||||
// otherwise, remove all of the other nodes between them...
|
||||
nodes = nodes
|
||||
.takeUntil(node => node.key == startKey)
|
||||
.take(1)
|
||||
.skipUntil(node => node.key == endKey)
|
||||
.take(Infinity)
|
||||
|
||||
// ...and remove the text from the first and last nodes
|
||||
let state = this.set('nodes', nodes)
|
||||
state = state.removeText(startNode, startOffset, startNode.text.length)
|
||||
state = state.removeText(endNode, 0, endOffset)
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the text from a `node`.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @param {Number} startOffset
|
||||
* @param {Number} endOffset
|
||||
* @return {State} state
|
||||
*/
|
||||
|
||||
removeText(node, startOffset, endOffset) {
|
||||
let { nodes } = this
|
||||
let { text } = node
|
||||
|
||||
text = text.filter((char, i) => {
|
||||
return i > startOffset && i < endOffset
|
||||
})
|
||||
|
||||
node = node.set('text', text)
|
||||
nodes = nodes.set(node.key, node)
|
||||
let state = this.set('nodes', nodes)
|
||||
return state
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default State
|
45
lib/models/text-node.js
Normal file
45
lib/models/text-node.js
Normal file
@@ -0,0 +1,45 @@
|
||||
|
||||
import CharacterList from './character-list'
|
||||
import { Record } from 'immutable'
|
||||
|
||||
/**
|
||||
* Record.
|
||||
*/
|
||||
|
||||
const TextNodeRecord = new Record({
|
||||
key: null,
|
||||
characters: new CharacterList()
|
||||
})
|
||||
|
||||
/**
|
||||
* TextNode.
|
||||
*/
|
||||
|
||||
class TextNode extends TextNodeRecord {
|
||||
|
||||
static create(attrs) {
|
||||
const characters = attrs.ranges.reduce((characters, range) => {
|
||||
const chars = range.text
|
||||
.split('')
|
||||
.map(char => {
|
||||
return {
|
||||
text: char,
|
||||
marks: range.marks
|
||||
}
|
||||
})
|
||||
return characters.concat(chars)
|
||||
}, [])
|
||||
|
||||
return new TextNode({
|
||||
key: attrs.key,
|
||||
characters: CharacterList.create(characters)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default TextNode
|
127
lib/plugins/core.js
Normal file
127
lib/plugins/core.js
Normal file
@@ -0,0 +1,127 @@
|
||||
|
||||
import keycode from 'keycode'
|
||||
import { IS_WINDOWS, IS_MAC } from '../utils/detect'
|
||||
|
||||
/**
|
||||
* The core plugin.
|
||||
*/
|
||||
|
||||
const CORE_PLUGIN = {
|
||||
|
||||
/**
|
||||
* The core `onKeyDown` handler.
|
||||
*
|
||||
* @param {Event} e
|
||||
* @param {Editor} editor
|
||||
* @return {State or Null} newState
|
||||
*/
|
||||
|
||||
onKeyDown(e, editor) {
|
||||
const state = editor.getState()
|
||||
const key = keycode(e.which)
|
||||
|
||||
switch (key) {
|
||||
case 'enter': {
|
||||
return state.split()
|
||||
}
|
||||
|
||||
case 'backspace': {
|
||||
// COMPAT: Windows has a special "cut" behavior for the shift key.
|
||||
if (IS_WINDOWS && e.shiftKey) return
|
||||
return isWord(e)
|
||||
? state.backspaceWord()
|
||||
: state.backspace()
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
// COMPAT: Windows has a special "cut" behavior for the shift key.
|
||||
if (IS_WINDOWS && e.shiftKey) return
|
||||
return isWord(e)
|
||||
? state.deleteWord()
|
||||
: state.delete()
|
||||
}
|
||||
|
||||
case 'y': {
|
||||
if (!isCtrl(e) || !IS_WINDOWS) return
|
||||
return state.redo()
|
||||
}
|
||||
|
||||
case 'z': {
|
||||
if (!isCommand(e)) return
|
||||
return IS_MAC && e.shiftKey
|
||||
? state.redo()
|
||||
: state.undo()
|
||||
}
|
||||
|
||||
default: {
|
||||
console.log('Unhandled key down.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Does an `e` have the the word-level modifier?
|
||||
*
|
||||
* @param {Event} e
|
||||
* @return {Boolean}
|
||||
*/
|
||||
|
||||
function isWord(e) {
|
||||
if (IS_MAC && e.altKey) return true
|
||||
if (e.ctrlKey) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Does an `e` have the the control modifier?
|
||||
*
|
||||
* @param {Event} e
|
||||
* @return {Boolean}
|
||||
*/
|
||||
|
||||
function isCtrl(e) {
|
||||
return e.ctrlKey && !e.altKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Does an `e` have the the option modifier?
|
||||
*
|
||||
* @param {Event} e
|
||||
* @return {Boolean}
|
||||
*/
|
||||
|
||||
function isOption(e) {
|
||||
return IS_MAC && e.altKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Does an `e` have the shift modifier?
|
||||
*
|
||||
* @param {Event} e
|
||||
* @return {Boolean}
|
||||
*/
|
||||
|
||||
function isShift(e) {
|
||||
return e.shiftKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Does an `e` have the the command modifier?
|
||||
*
|
||||
* @param {Event} e
|
||||
* @return {Boolean}
|
||||
*/
|
||||
|
||||
function isCommand(e) {
|
||||
return IS_MAC
|
||||
? e.metaKey && !e.altKey
|
||||
: e.ctrlKey && !e.altKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default CORE_PLUGIN
|
18
lib/utils/detect.js
Normal file
18
lib/utils/detect.js
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
import browser from 'detect-browser'
|
||||
import Parser from 'ua-parser-js'
|
||||
|
||||
/**
|
||||
* Detections.
|
||||
*/
|
||||
|
||||
export const IS_ANDROID = browser.name === 'android'
|
||||
export const IS_CHROME = browser.name === 'chrome'
|
||||
export const IS_EDGE = browser.name === 'edge'
|
||||
export const IS_FIREFOX = browser.name === 'firefox'
|
||||
export const IS_IE = browser.name === 'ie'
|
||||
export const IS_IOS = browser.name === 'ios'
|
||||
export const IS_MAC = new Parser().getOS().name === 'Mac OS'
|
||||
export const IS_UBUNTU = new Parser().getOS().name === 'Ubuntu'
|
||||
export const IS_SAFARI = browser.name === 'safari'
|
||||
export const IS_WINDOWS = new Parser().getOS().name.includes('Windows')
|
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "editor",
|
||||
"private": true,
|
||||
"main": "./lib/index.js",
|
||||
"dependencies": {
|
||||
"detect-browser": "^1.3.3",
|
||||
"immutable": "^3.8.1",
|
||||
"keycode": "^2.1.2",
|
||||
"lodash": "^4.13.1",
|
||||
"react": "^15.1.0",
|
||||
"to-camel-case": "^1.0.0",
|
||||
"ua-parser-js": "^0.7.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-core": "^6.9.1",
|
||||
"babel-polyfill": "^6.9.1",
|
||||
"babel-preset-es2015": "^6.9.0",
|
||||
"babel-preset-react": "^6.5.0",
|
||||
"babel-preset-stage-0": "^6.5.0",
|
||||
"babelify": "^7.3.0",
|
||||
"browserify": "^13.0.1",
|
||||
"mocha": "^2.5.3",
|
||||
"mocha-phantomjs": "^4.0.2",
|
||||
"react-dom": "^15.1.0",
|
||||
"standard": "^7.1.2",
|
||||
"watchify": "^3.7.0"
|
||||
}
|
||||
}
|
21
test/browser.js
Normal file
21
test/browser.js
Normal file
@@ -0,0 +1,21 @@
|
||||
|
||||
/**
|
||||
* Polyfills.
|
||||
*/
|
||||
|
||||
require('babel-polyfill')
|
||||
|
||||
/**
|
||||
* Dependencies.
|
||||
*/
|
||||
|
||||
const assert = require('assert')
|
||||
const Editor = require('..')
|
||||
|
||||
/**
|
||||
* Tests.
|
||||
*/
|
||||
|
||||
describe('browser', () => {
|
||||
|
||||
})
|
11
test/server.js
Normal file
11
test/server.js
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
const assert = require('assert')
|
||||
const Editor = require('..')
|
||||
|
||||
/**
|
||||
* Tests.
|
||||
*/
|
||||
|
||||
describe('server', () => {
|
||||
|
||||
})
|
14
test/support/browser.html
Normal file
14
test/support/browser.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="mocha.css" />
|
||||
<title>Editor | Tests</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="mocha"></div>
|
||||
<script src="mocha.js"></script>
|
||||
<script>mocha.ui('bdd')</script>
|
||||
<script src="build.js"></script>
|
||||
<script>mocha.run()</script>
|
||||
</body>
|
||||
</html>
|
314
test/support/mocha.css
Normal file
314
test/support/mocha.css
Normal file
@@ -0,0 +1,314 @@
|
||||
@charset "utf-8";
|
||||
|
||||
body {
|
||||
margin:0;
|
||||
}
|
||||
|
||||
#mocha {
|
||||
font: 20px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
margin: 60px 50px;
|
||||
}
|
||||
|
||||
#mocha ul,
|
||||
#mocha li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#mocha ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#mocha h1,
|
||||
#mocha h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#mocha h1 {
|
||||
margin-top: 15px;
|
||||
font-size: 1em;
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
#mocha h1 a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#mocha h1 a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#mocha .suite .suite h1 {
|
||||
margin-top: 0;
|
||||
font-size: .8em;
|
||||
}
|
||||
|
||||
#mocha .hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#mocha h2 {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#mocha .suite {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
#mocha .test {
|
||||
margin-left: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#mocha .test.pending:hover h2::after {
|
||||
content: '(pending)';
|
||||
font-family: arial, sans-serif;
|
||||
}
|
||||
|
||||
#mocha .test.pass.medium .duration {
|
||||
background: #c09853;
|
||||
}
|
||||
|
||||
#mocha .test.pass.slow .duration {
|
||||
background: #b94a48;
|
||||
}
|
||||
|
||||
#mocha .test.pass::before {
|
||||
content: '✓';
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
float: left;
|
||||
margin-right: 5px;
|
||||
color: #00d6b2;
|
||||
}
|
||||
|
||||
#mocha .test.pass .duration {
|
||||
font-size: 9px;
|
||||
margin-left: 5px;
|
||||
padding: 2px 5px;
|
||||
color: #fff;
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.2);
|
||||
-moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.2);
|
||||
box-shadow: inset 0 1px 1px rgba(0,0,0,.2);
|
||||
-webkit-border-radius: 5px;
|
||||
-moz-border-radius: 5px;
|
||||
-ms-border-radius: 5px;
|
||||
-o-border-radius: 5px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#mocha .test.pass.fast .duration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#mocha .test.pending {
|
||||
color: #0b97c4;
|
||||
}
|
||||
|
||||
#mocha .test.pending::before {
|
||||
content: '◦';
|
||||
color: #0b97c4;
|
||||
}
|
||||
|
||||
#mocha .test.fail {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
#mocha .test.fail pre {
|
||||
color: black;
|
||||
}
|
||||
|
||||
#mocha .test.fail::before {
|
||||
content: '✖';
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
float: left;
|
||||
margin-right: 5px;
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
#mocha .test pre.error {
|
||||
color: #c00;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#mocha .test .html-error {
|
||||
overflow: auto;
|
||||
color: black;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
float: left;
|
||||
clear: left;
|
||||
font: 12px/1.5 monaco, monospace;
|
||||
margin: 5px;
|
||||
padding: 15px;
|
||||
border: 1px solid #eee;
|
||||
max-width: 85%; /*(1)*/
|
||||
max-width: calc(100% - 42px); /*(2)*/
|
||||
max-height: 300px;
|
||||
word-wrap: break-word;
|
||||
border-bottom-color: #ddd;
|
||||
-webkit-border-radius: 3px;
|
||||
-webkit-box-shadow: 0 1px 3px #eee;
|
||||
-moz-border-radius: 3px;
|
||||
-moz-box-shadow: 0 1px 3px #eee;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#mocha .test .html-error pre.error {
|
||||
border: none;
|
||||
-webkit-border-radius: none;
|
||||
-webkit-box-shadow: none;
|
||||
-moz-border-radius: none;
|
||||
-moz-box-shadow: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-top: 18px;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* (1): approximate for browsers not supporting calc
|
||||
* (2): 42 = 2*15 + 2*10 + 2*1 (padding + margin + border)
|
||||
* ^^ seriously
|
||||
*/
|
||||
#mocha .test pre {
|
||||
display: block;
|
||||
float: left;
|
||||
clear: left;
|
||||
font: 12px/1.5 monaco, monospace;
|
||||
margin: 5px;
|
||||
padding: 15px;
|
||||
border: 1px solid #eee;
|
||||
max-width: 85%; /*(1)*/
|
||||
max-width: calc(100% - 42px); /*(2)*/
|
||||
word-wrap: break-word;
|
||||
border-bottom-color: #ddd;
|
||||
-webkit-border-radius: 3px;
|
||||
-webkit-box-shadow: 0 1px 3px #eee;
|
||||
-moz-border-radius: 3px;
|
||||
-moz-box-shadow: 0 1px 3px #eee;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#mocha .test h2 {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#mocha .test a.replay {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 0;
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
display: block;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
background: #eee;
|
||||
font-size: 15px;
|
||||
-moz-border-radius: 15px;
|
||||
border-radius: 15px;
|
||||
-webkit-transition: opacity 200ms;
|
||||
-moz-transition: opacity 200ms;
|
||||
transition: opacity 200ms;
|
||||
opacity: 0.3;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
#mocha .test:hover a.replay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#mocha-report.pass .test.fail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#mocha-report.fail .test.pass {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#mocha-report.pending .test.pass,
|
||||
#mocha-report.pending .test.fail {
|
||||
display: none;
|
||||
}
|
||||
#mocha-report.pending .test.pass.pending {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#mocha-error {
|
||||
color: #c00;
|
||||
font-size: 1.5em;
|
||||
font-weight: 100;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#mocha-stats {
|
||||
position: fixed;
|
||||
top: 15px;
|
||||
right: 10px;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
color: #888;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#mocha-stats .progress {
|
||||
float: right;
|
||||
padding-top: 0;
|
||||
|
||||
/**
|
||||
* Set safe initial values, so mochas .progress does not inherit these
|
||||
* properties from Bootstrap .progress (which causes .progress height to
|
||||
* equal line height set in Bootstrap).
|
||||
*/
|
||||
height: auto;
|
||||
box-shadow: none;
|
||||
background-color: initial;
|
||||
}
|
||||
|
||||
#mocha-stats em {
|
||||
color: black;
|
||||
}
|
||||
|
||||
#mocha-stats a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#mocha-stats a:hover {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
#mocha-stats li {
|
||||
display: inline-block;
|
||||
margin: 0 5px;
|
||||
list-style: none;
|
||||
padding-top: 11px;
|
||||
}
|
||||
|
||||
#mocha-stats canvas {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
#mocha code .comment { color: #ddd; }
|
||||
#mocha code .init { color: #2f6fad; }
|
||||
#mocha code .string { color: #5890ad; }
|
||||
#mocha code .keyword { color: #8a6343; }
|
||||
#mocha code .number { color: #2f6fad; }
|
||||
|
||||
@media screen and (max-device-width: 480px) {
|
||||
#mocha {
|
||||
margin: 60px 0px;
|
||||
}
|
||||
|
||||
#mocha #stats {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
13135
test/support/mocha.js
Normal file
13135
test/support/mocha.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user