1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-09-02 19:52:32 +02:00

add paste html example with first stab at html serializer

This commit is contained in:
Ian Storm Taylor
2016-06-29 10:43:22 -07:00
parent 78a902d7a0
commit 2f2437476e
8 changed files with 482 additions and 25 deletions

View File

@@ -6,11 +6,6 @@ html {
background: #eee; background: #eee;
} }
main {
max-width: 42em;
margin: 0 auto;
}
p { p {
margin: 0; margin: 0;
} }
@@ -73,8 +68,10 @@ td {
*/ */
.example { .example {
background: #fff; max-width: 42em;
margin: 0 auto;
padding: 20px; padding: 20px;
background: #fff;
} }
.editor > * > * + * { .editor > * > * + * {

View File

@@ -11,6 +11,7 @@ import AutoMarkdown from './auto-markdown'
import HoveringMenu from './hovering-menu' import HoveringMenu from './hovering-menu'
import Images from './images' import Images from './images'
import Links from './links' import Links from './links'
import PasteHtml from './paste-html'
import PlainText from './plain-text' import PlainText from './plain-text'
import RichText from './rich-text' import RichText from './rich-text'
import Tables from './tables' import Tables from './tables'
@@ -47,17 +48,31 @@ class App extends React.Component {
renderTabBar() { renderTabBar() {
return ( return (
<div className="tabs"> <div className="tabs">
<Link className="tab" activeClassName="active" to="rich-text">Rich Text</Link> {this.renderTab('Rich Text', 'rich-text')}
<Link className="tab" activeClassName="active" to="plain-text">Plain Text</Link> {this.renderTab('Plain Text', 'plain-text')}
<Link className="tab" activeClassName="active" to="auto-markdown">Auto-markdown</Link> {this.renderTab('Auto-markdown', 'auto-markdown')}
<Link className="tab" activeClassName="active" to="hovering-menu">Hovering Menu</Link> {this.renderTab('Hovering Menu', 'hovering-menu')}
<Link className="tab" activeClassName="active" to="links">Links</Link> {this.renderTab('Links', 'links')}
<Link className="tab" activeClassName="active" to="images">Images</Link> {this.renderTab('Images', 'images')}
<Link className="tab" activeClassName="active" to="tables">Tables</Link> {this.renderTab('Tables', 'tables')}
{this.renderTab('Paste HTML', 'paste-html')}
</div> </div>
) )
} }
/**
* Render a tab with `name` and `slug`.
*
* @param {String} name
* @param {String} slug
*/
renderTab(name, slug) {
return (
<Link className="tab" activeClassName="active" to={slug}>{name}</Link>
)
}
/** /**
* Render the example. * Render the example.
* *
@@ -88,6 +103,7 @@ const router = (
<Route path="hovering-menu" component={HoveringMenu} /> <Route path="hovering-menu" component={HoveringMenu} />
<Route path="images" component={Images} /> <Route path="images" component={Images} />
<Route path="links" component={Links} /> <Route path="links" component={Links} />
<Route path="paste-html" component={PasteHtml} />
<Route path="plain-text" component={PlainText} /> <Route path="plain-text" component={PlainText} />
<Route path="rich-text" component={RichText} /> <Route path="rich-text" component={RichText} />
<Route path="tables" component={Tables} /> <Route path="tables" component={Tables} />

View File

@@ -0,0 +1,202 @@
import Editor, { Html, Raw } from '../..'
import React from 'react'
import state from './state.json'
/**
* Tags to blocks.
*
* @type {Object}
*/
const BLOCKS = {
p: 'paragraph',
li: 'list-item',
ul: 'bulleted-list',
ol: 'numbered-list',
blockquote: 'quote',
pre: 'code',
h1: 'heading-one',
h2: 'heading-two',
h3: 'heading-three',
h4: 'heading-four',
h5: 'heading-five',
h6: 'heading-six'
}
/**
* Tags to marks.
*
* @type {Object}
*/
const MARKS = {
b: 'bold',
strong: 'bold',
i: 'italic',
em: 'italic',
u: 'underline',
s: 'strikethrough',
code: 'code'
}
/**
* Serializer rules.
*
* @type {Array}
*/
const RULES = [
{
deserialize(el) {
const block = BLOCKS[el.tagName]
if (!block) return
return {
kind: 'block',
type: block,
nodes: next(el.children)
}
}
},
{
deserialize(el, next) {
const mark = MARKS[el.tagName]
if (!mark) return
return {
kind: 'mark',
type: mark,
nodes: next(el.children)
}
}
},
{
// Special case for code blocks, which need to grab the nested children.
deserialize(el, next) {
if (el.tagName != 'pre') return
const code = el.children[0]
return {
kind: 'block',
type: 'code-block',
nodes: next(code.children)
}
}
},
{
// Special case for links, to grab their href.
deserialize(el, next) {
if (el.tagName != 'a') return
return {
kind: 'inline',
type: 'link',
nodes: next(el.children),
data: {
href: el.attribs.href
}
}
}
}
]
/**
* Create a new HTML serializer with `RULES`.
*/
const serializer = new Html(RULES)
/**
* The rich text example.
*
* @type {Component} PasteHtml
*/
class PasteHtml extends React.Component {
state = {
state: Raw.deserialize(state)
};
onPaste(e, paste, state, editor) {
if (paste.type != 'html') return
const { html } = paste
const { document } = serializer.deserialize(html)
return state
.transform()
.insertFragment(document)
.apply()
}
render() {
return (
<div className="editor">
<Editor
state={this.state.state}
renderNode={node => this.renderNode(node)}
renderMark={mark => this.renderMark(mark)}
onPaste={(...args) => this.onPaste(...args)}
onChange={(state) => {
console.groupCollapsed('Change!')
console.log('Document:', state.document.toJS())
console.log('Selection:', state.selection.toJS())
console.log('Content:', Raw.serialize(state))
console.groupEnd()
this.setState({ state })
}}
/>
</div>
)
}
renderNode(node) {
switch (node.type) {
case 'code': return (props) => <pre><code>{props.chidlren}</code></pre>
case 'quote': return (props) => <blockquote>{props.children}</blockquote>
case 'bulleted-list': return (props) => <ul>{props.chidlren}</ul>
case 'heading-one': return (props) => <h1>{props.children}</h1>
case 'heading-two': return (props) => <h2>{props.children}</h2>
case 'list-item': return (props) => <li>{props.chidlren}</li>
case 'numbered-list': return (props) => <ol>{props.children}</ol>
case 'paragraph': return (props) => <p>{props.children}</p>
case 'link': return (props) => {
const { data } = props.node
const href = data.get('href')
return <a href={href}>{props.children}</a>
}
}
}
renderMark(mark) {
switch (mark.type) {
case 'bold': {
return {
fontWeight: 'bold'
}
}
case 'code': {
return {
fontFamily: 'monospace',
backgroundColor: '#eee',
padding: '3px',
borderRadius: '4px'
}
}
case 'italic': {
return {
fontStyle: 'italic'
}
}
case 'underlined': {
return {
textDecoration: 'underline'
}
}
}
}
}
/**
* Export.
*/
export default PasteHtml

View File

@@ -0,0 +1,32 @@
{
"nodes": [
{
"kind": "block",
"type": "paragraph",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": "By default, pasting content into a Slate editor will use the content's plain text representation. This is fine for some use cases, but sometimes you want to actually be able to paste in content and have it parsed into blocks and links and things. To do this, you need to add a parser that triggers on paste. This is an example of doing exactly that!"
}
]
}
]
},
{
"kind": "block",
"type": "paragraph",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": "Try it out for yourself! Copy and paste some HTML content from another site into this editor."
}
]
}
]
}
]
}

View File

@@ -24,4 +24,5 @@ export { default as Text } from './models/text'
* Serializers. * Serializers.
*/ */
export { default as Html } from './serializers/html'
export { default as Raw } from './serializers/raw' export { default as Raw } from './serializers/raw'

View File

@@ -805,7 +805,6 @@ const Node = {
normalize() { normalize() {
let node = this let node = this
const texts = node.getTextNodes()
// If this node has no children, add a text node. // If this node has no children, add a text node.
if (node.nodes.size == 0) { if (node.nodes.size == 0) {
@@ -823,7 +822,7 @@ const Node = {
}) })
// See if there are any adjacent text nodes. // See if there are any adjacent text nodes.
let firstAdjacent = node.findDescendant((child) => { let first = node.findDescendant((child) => {
if (child.kind != 'text') return if (child.kind != 'text') return
const parent = node.getParent(child) const parent = node.getParent(child)
const next = parent.getNextSibling(child) const next = parent.getNextSibling(child)
@@ -831,18 +830,23 @@ const Node = {
}) })
// If no text nodes are adjacent, abort. // If no text nodes are adjacent, abort.
if (!firstAdjacent) return node if (!first) return node
// Fix an adjacent text node if one exists. // Fix an adjacent text node if one exists.
let parent = node.getParent(firstAdjacent) let parent = node.getParent(first)
const second = parent.getNextSibling(firstAdjacent) const isParent = node == parent
const characters = firstAdjacent.characters.concat(second.characters) const second = parent.getNextSibling(first)
firstAdjacent = firstAdjacent.merge({ characters }) const characters = first.characters.concat(second.characters)
parent = parent.updateDescendant(firstAdjacent) first = first.merge({ characters })
parent = parent.updateDescendant(first)
// Then remove the second node. // Then remove the second text node.
parent = parent.removeDescendant(second) parent = parent.removeDescendant(second)
node = node.updateDescendant(parent)
// And update the node.
node = isParent
? parent
: node.updateDescendant(parent)
// Recurse by normalizing again. // Recurse by normalizing again.
return node.normalize() return node.normalize()
@@ -911,13 +915,17 @@ const Node = {
*/ */
updateDescendant(node) { updateDescendant(node) {
this.assertHasDescendant(node)
if (this.hasChild(node)) { if (this.hasChild(node)) {
const nodes = this.nodes.map(child => child.key == node.key ? node : child) const nodes = this.nodes.map(child => child.key == node.key ? node : child)
return this.merge({ nodes }) return this.merge({ nodes })
} }
const nodes = this.nodes.map((child) => { const nodes = this.nodes.map((child) => {
return child.kind == 'text' ? child : child.updateDescendant(node) if (child.kind == 'text') return child
if (!child.hasDescendant(node)) return child
return child.updateDescendant(node)
}) })
return this.merge({ nodes }) return this.merge({ nodes })

199
lib/serializers/html.js Normal file
View File

@@ -0,0 +1,199 @@
import Block from '../models/block'
import Document from '../models/document'
import Inline from '../models/inline'
import Mark from '../models/mark'
import Raw from './raw'
import State from '../models/state'
import Text from '../models/text'
import cheerio from 'cheerio'
/**
* A rule to serialize text nodes.
*/
const TEXT_RULE = {
deserialize(el) {
if (el.type != 'text') return
return {
kind: 'text',
ranges: [
{
text: el.data
}
]
}
}
}
/**
* A rule to serialize <br> nodes.
*/
const BR_RULE = {
deserialize(el) {
if (el.tagName != 'br') return
return {
kind: 'text',
ranges: [
{
text: '\n'
}
]
}
}
}
/**
* HTML serializer.
*/
class Html {
/**
* Create a new serializer with `rules`.
*
* @param {Array} rules
* @return {Html} serializer
*/
constructor(rules = []) {
this.rules = [
...rules,
TEXT_RULE,
BR_RULE
]
}
/**
* Deserialize pasted HTML.
*
* @param {String} html
* @return {State} state
*/
deserialize(html) {
const $ = cheerio.load(html).root()
const children = $.children().toArray()
let nodes = this.deserializeElements(children)
// HACK: ensure for now that all top-level inline are wrapping into a block.
nodes = nodes.reduce((nodes, node, i, original) => {
if (node.kind == 'block') {
nodes.push(node)
return nodes
}
if (i > 0 && original[i - 1].kind != 'block') {
const block = nodes[nodes.length - 1]
block.nodes.push(node)
return nodes
}
const block = {
kind: 'block',
type: 'paragraph',
nodes: [node]
}
nodes.push(block)
return nodes
}, [])
const state = Raw.deserialize({ nodes })
return state
}
/**
* Deserialize an array of Cheerio `elements`.
*
* @param {Array} elements
* @return {Array} nodes
*/
deserializeElements(elements = []) {
let nodes = []
elements.forEach((element) => {
const node = this.deserializeElement(element)
if (!node) return
if (Array.isArray(node)) {
nodes = nodes.concat(node)
} else {
nodes.push(node)
}
})
return nodes
}
/**
* Deserialize a Cheerio `element`.
*
* @param {Object} element
* @return {Mixed} node
*/
deserializeElement(element) {
let node
const next = (elements) => {
return Array.isArray(elements)
? this.deserializeElements(elements)
: this.deserializeElement(elements)
}
for (const rule of this.rules) {
const ret = rule.deserialize(element, next)
if (!ret) continue
node = ret.kind == 'mark'
? this.deserializeMark(ret)
: ret
}
return node
? node
: next(element.children)
}
/**
* Deserialize a `mark` object.
*
* @param {Object} mark
* @return {Array} nodes
*/
deserializeMark(mark) {
const { type, data } = mark
const applyMark = (node) => {
if (node.kind == 'mark') return this.deserializeMark(node)
if (node.kind != 'text') {
node.nodes = node.nodes.map(applyMark)
} else {
node.ranges = node.ranges.map((range) => {
range.marks = range.marks || []
range.marks.push({ type, data })
return range
})
}
return node
}
return mark.nodes.reduce((nodes, node) => {
const ret = applyMark(node)
if (Array.isArray(ret)) return nodes.concat(ret)
nodes.push(ret)
return nodes
}, [])
}
}
/**
* Export.
*/
export default Html

View File

@@ -20,6 +20,7 @@
"babel-preset-stage-0": "^6.5.0", "babel-preset-stage-0": "^6.5.0",
"babelify": "^7.3.0", "babelify": "^7.3.0",
"browserify": "^13.0.1", "browserify": "^13.0.1",
"cheerio": "^0.20.0",
"component-type": "^1.2.1", "component-type": "^1.2.1",
"exorcist": "^0.4.0", "exorcist": "^0.4.0",
"mocha": "^2.5.3", "mocha": "^2.5.3",
@@ -33,6 +34,7 @@
"standard": "^7.1.2", "standard": "^7.1.2",
"to-camel-case": "^1.0.0", "to-camel-case": "^1.0.0",
"to-title-case": "^1.0.0", "to-title-case": "^1.0.0",
"watchify": "^3.7.0" "watchify": "^3.7.0",
"xml2js": "^0.4.16"
} }
} }