From 2f2437476e647b8087feb1604684ab09ba6870de Mon Sep 17 00:00:00 2001 From: Ian Storm Taylor Date: Wed, 29 Jun 2016 10:43:22 -0700 Subject: [PATCH] add paste html example with first stab at html serializer --- examples/index.css | 9 +- examples/index.js | 30 +++-- examples/paste-html/index.js | 202 +++++++++++++++++++++++++++++++++ examples/paste-html/state.json | 32 ++++++ lib/index.js | 1 + lib/models/node.js | 30 +++-- lib/serializers/html.js | 199 ++++++++++++++++++++++++++++++++ package.json | 4 +- 8 files changed, 482 insertions(+), 25 deletions(-) create mode 100644 examples/paste-html/index.js create mode 100644 examples/paste-html/state.json create mode 100644 lib/serializers/html.js diff --git a/examples/index.css b/examples/index.css index b00b4dfc2..3fdd11297 100644 --- a/examples/index.css +++ b/examples/index.css @@ -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 > * > * + * { diff --git a/examples/index.js b/examples/index.js index 545866ef9..df82fa2ff 100644 --- a/examples/index.js +++ b/examples/index.js @@ -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 (
- Rich Text - Plain Text - Auto-markdown - Hovering Menu - Links - Images - Tables + {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')}
) } + /** + * Render a tab with `name` and `slug`. + * + * @param {String} name + * @param {String} slug + */ + + renderTab(name, slug) { + return ( + {name} + ) + } + /** * Render the example. * @@ -88,6 +103,7 @@ const router = ( + diff --git a/examples/paste-html/index.js b/examples/paste-html/index.js new file mode 100644 index 000000000..a4ccb9275 --- /dev/null +++ b/examples/paste-html/index.js @@ -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 ( +
+ 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 }) + }} + /> +
+ ) + } + + renderNode(node) { + switch (node.type) { + case 'code': return (props) =>
{props.chidlren}
+ case 'quote': return (props) =>
{props.children}
+ case 'bulleted-list': return (props) =>
    {props.chidlren}
+ case 'heading-one': return (props) =>

{props.children}

+ case 'heading-two': return (props) =>

{props.children}

+ case 'list-item': return (props) =>
  • {props.chidlren}
  • + case 'numbered-list': return (props) =>
      {props.children}
    + case 'paragraph': return (props) =>

    {props.children}

    + case 'link': return (props) => { + const { data } = props.node + const href = data.get('href') + return {props.children} + } + } + } + + 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 diff --git a/examples/paste-html/state.json b/examples/paste-html/state.json new file mode 100644 index 000000000..2afc9b1d6 --- /dev/null +++ b/examples/paste-html/state.json @@ -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." + } + ] + } + ] + } + ] +} diff --git a/lib/index.js b/lib/index.js index 5cc8af9fe..50b3b1fcf 100644 --- a/lib/index.js +++ b/lib/index.js @@ -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' diff --git a/lib/models/node.js b/lib/models/node.js index 034061d22..fc10e97b3 100644 --- a/lib/models/node.js +++ b/lib/models/node.js @@ -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 }) diff --git a/lib/serializers/html.js b/lib/serializers/html.js new file mode 100644 index 000000000..67e279c85 --- /dev/null +++ b/lib/serializers/html.js @@ -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
    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 diff --git a/package.json b/package.json index 6d51e4b30..271887506 100644 --- a/package.json +++ b/package.json @@ -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" } }