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:
@@ -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 > * > * + * {
|
||||
|
@@ -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} />
|
||||
|
202
examples/paste-html/index.js
Normal file
202
examples/paste-html/index.js
Normal 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
|
32
examples/paste-html/state.json
Normal file
32
examples/paste-html/state.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@@ -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'
|
||||
|
@@ -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
199
lib/serializers/html.js
Normal 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
|
@@ -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"
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user