1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-31 10:51:44 +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;
}
main {
max-width: 42em;
margin: 0 auto;
}
p {
margin: 0;
}
@@ -73,8 +68,10 @@ td {
*/
.example {
background: #fff;
max-width: 42em;
margin: 0 auto;
padding: 20px;
background: #fff;
}
.editor > * > * + * {

View File

@@ -11,6 +11,7 @@ import AutoMarkdown from './auto-markdown'
import HoveringMenu from './hovering-menu'
import Images from './images'
import Links from './links'
import PasteHtml from './paste-html'
import PlainText from './plain-text'
import RichText from './rich-text'
import Tables from './tables'
@@ -47,17 +48,31 @@ class App extends React.Component {
renderTabBar() {
return (
<div className="tabs">
<Link className="tab" activeClassName="active" to="rich-text">Rich Text</Link>
<Link className="tab" activeClassName="active" to="plain-text">Plain Text</Link>
<Link className="tab" activeClassName="active" to="auto-markdown">Auto-markdown</Link>
<Link className="tab" activeClassName="active" to="hovering-menu">Hovering Menu</Link>
<Link className="tab" activeClassName="active" to="links">Links</Link>
<Link className="tab" activeClassName="active" to="images">Images</Link>
<Link className="tab" activeClassName="active" to="tables">Tables</Link>
{this.renderTab('Rich Text', 'rich-text')}
{this.renderTab('Plain Text', 'plain-text')}
{this.renderTab('Auto-markdown', 'auto-markdown')}
{this.renderTab('Hovering Menu', 'hovering-menu')}
{this.renderTab('Links', 'links')}
{this.renderTab('Images', 'images')}
{this.renderTab('Tables', 'tables')}
{this.renderTab('Paste HTML', 'paste-html')}
</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.
*
@@ -88,6 +103,7 @@ const router = (
<Route path="hovering-menu" component={HoveringMenu} />
<Route path="images" component={Images} />
<Route path="links" component={Links} />
<Route path="paste-html" component={PasteHtml} />
<Route path="plain-text" component={PlainText} />
<Route path="rich-text" component={RichText} />
<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.
*/
export { default as Html } from './serializers/html'
export { default as Raw } from './serializers/raw'

View File

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

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",
"babelify": "^7.3.0",
"browserify": "^13.0.1",
"cheerio": "^0.20.0",
"component-type": "^1.2.1",
"exorcist": "^0.4.0",
"mocha": "^2.5.3",
@@ -33,6 +34,7 @@
"standard": "^7.1.2",
"to-camel-case": "^1.0.0",
"to-title-case": "^1.0.0",
"watchify": "^3.7.0"
"watchify": "^3.7.0",
"xml2js": "^0.4.16"
}
}