substance
Version:
Substance is a JavaScript library for web-based content editing. It provides building blocks for realizing custom text editors and web-based publishing system. It is developed to power our online editing platform [Substance](http://substance.io).
307 lines (286 loc) • 11.6 kB
JavaScript
import { platform } from '../util'
import { DefaultDOMElement } from '../dom'
import { documentHelpers, JSONConverter } from '../model'
const INLINENODES = ['a', 'b', 'big', 'i', 'small', 'tt', 'abbr', 'acronym', 'cite', 'code', 'dfn', 'em', 'kbd', 'strong', 'samp', 'time', 'var', 'bdo', 'br', 'img', 'map', 'object', 'q', 'script', 'span', 'sub', 'sup', 'button', 'input', 'label', 'select', 'textarea'].reduce((m, n) => { m[n] = true; return m }, {})
/*
A rewrite of the original Substance.Clipboard, which uses a better JSONConverter implementation.
Note: this should eventually moved back into Substance core.
*/
export default class Clipboard {
copy (clipboardData, context) {
// content specific manipulation API
const editorSession = context.editorSession
const snippet = editorSession.copy()
this._setClipboardData(clipboardData, context, snippet)
}
cut (clipboardData, context) {
const editorSession = context.editorSession
const snippet = editorSession.cut()
this._setClipboardData(clipboardData, context, snippet)
}
paste (clipboardData, context, options = {}) {
const types = {}
for (let i = 0; i < clipboardData.types.length; i++) {
types[clipboardData.types[i]] = true
}
const html = types['text/html'] ? clipboardData.getData('text/html') : ''
let success = false
if (html && !options.plainTextOnly) {
success = this._pasteHtml(html, context, options)
}
if (!success) {
// in all other cases we fall back to plain-text
const plainText = types['text/plain'] ? clipboardData.getData('text/plain') : ''
this._pasteText(plainText, context, options)
}
}
_setClipboardData (clipboardData, context, snippet) {
const elements = this._createClipboardHtmlElements(context, snippet)
const plainText = this._createClipboardText(context, snippet, elements)
const html = this._createClipboardHtml(context, snippet, elements)
clipboardData.setData('text/plain', plainText)
if (html) {
clipboardData.setData('text/html', html)
}
}
_createClipboardHtmlElements (context, snippet) {
const htmlExporter = context.config.createExporter('html')
if (htmlExporter) {
return htmlExporter.convertContainer(snippet, snippet.getContainer().getPath())
}
}
_createClipboardText (context, snippet, htmlElements) {
const config = context.config
const textExporter = config.createExporter('text')
if (textExporter) {
return textExporter.exportNode(snippet.getContainer())
} else if (htmlElements) {
return htmlElements.map(el => el.textContent).join('\n')
} else {
return ''
}
}
_createClipboardHtml (context, snippet, elements) {
if (elements) {
// special treatment for a text snippet
let snippetHtml
if (elements.length === 1 && elements[0].attr('data-id') === documentHelpers.TEXT_SNIPPET_ID) {
snippetHtml = elements[0].innerHTML
} else {
snippetHtml = elements.map(el => {
return el.outerHTML
}).join('')
}
const jsonConverter = new JSONConverter()
const jsonStr = JSON.stringify(jsonConverter.exportDocument(snippet))
const substanceContent = `<script id="substance-clipboard" type="application/json">${jsonStr}</script>`
const html = '<html><head>' + substanceContent + '</head><body>' + snippetHtml + '</body></html>'
return html
}
}
_pasteHtml (html, context, options = {}) {
let htmlDoc
try {
htmlDoc = DefaultDOMElement.parseHTML(html)
} catch (err) {
console.error('Could not parse HTML received from the clipboard', err)
return false
}
// when copying from a substance editor we store JSON in a script tag in the head
// If the import fails e.g. because the schema is incompatible
// we fall back to plain HTML import
let snippet
if (html.search(/script id=.substance-clipboard./) >= 0) {
const substanceData = htmlDoc.find('#substance-clipboard')
if (substanceData) {
const jsonStr = substanceData.textContent
try {
snippet = this._importFromJSON(context, jsonStr)
} finally {
if (!snippet) {
console.error('Could not convert clipboard content.')
}
}
}
}
if (!snippet) {
const state = {}
Object.assign(state, this._detectApplicationType(html, htmlDoc))
// Under windows and in Microsoft Word we can exploit the fact
// that the paste content is wrapped inside <!--StartFragment--> and <!--EndFragment-->
if (platform.isWindows || state.isMicrosoftWord) {
// very strange: this was not working at some day
// let match = /<!--StartFragment-->(.*)<!--EndFragment-->/.exec(html)
// ... but still this
const START_FRAGMENT = '<!--StartFragment-->'
const END_FRAGMENT = '<!--EndFragment-->'
const mStart = html.indexOf(START_FRAGMENT)
if (mStart >= 0) {
const mEnd = html.indexOf(END_FRAGMENT)
const fragment = html.slice(mStart + START_FRAGMENT.length, mEnd)
htmlDoc = DefaultDOMElement.parseHTML(fragment)
}
}
// Note: because we are parsing the HTML not as snippet
// the parser will always create a full HTML document
// and there will always be a <body>
// In case, the clipboard HTML is just a snippet
// the body will contain the parsed snippet
let bodyEl = htmlDoc.find('body')
bodyEl = this._sanitizeBody(state, bodyEl)
if (!bodyEl) {
console.error('Invalid HTML.')
return false
}
bodyEl = this._wrapIntoParagraph(bodyEl)
snippet = context.editorSession.getDocument().createSnippet()
const htmlImporter = context.config.createImporter('html', snippet)
const container = snippet.get(documentHelpers.SNIPPET_ID)
bodyEl.getChildren().forEach(el => {
const node = htmlImporter.convertElement(el)
if (node) {
container.append(node.id)
}
})
}
return context.editorSession.paste(snippet, options)
}
_pasteText (text, context) {
context.editorSession.insertText(text)
}
_importFromJSON (context, jsonStr) {
const snippet = context.editorSession.getDocument().newInstance()
const jsonData = JSON.parse(jsonStr)
const converter = new JSONConverter()
converter.importDocument(snippet, jsonData)
return snippet
}
_detectApplicationType (html, htmlDoc) {
const state = {}
const generatorMeta = htmlDoc.find('meta[name="generator"]')
const xmnlsw = htmlDoc.find('html').getAttribute('xmlns:w')
if (generatorMeta) {
const generator = generatorMeta.getAttribute('content')
if (generator.indexOf('LibreOffice') > -1) {
state.isLibreOffice = true
}
} else if (xmnlsw) {
if (xmnlsw.indexOf('office:word') > -1) {
state.isMicrosoftWord = true
}
} else if (html.indexOf('docs-internal-guid') > -1) {
state.isGoogleDoc = true
}
return state
}
_sanitizeBody (state, body) {
// Remove <meta> element
body.findAll('meta').forEach(el => el.remove())
// Some word processors are exporting new lines instead of spaces
// for these editors we will replace all new lines with space
if (state.isLibreOffice || state.isMicrosoftWord) {
const bodyHtml = body.getInnerHTML()
body.setInnerHTML(bodyHtml.replace(/\r\n|\r|\n/g, ' '))
}
if (state.isGoogleDoc) {
body = this._fixupGoogleDocsBody(state, body)
}
return body
}
_fixupGoogleDocsBody (state, body) {
if (!body) return
// Google Docs has a strange convention to use a bold tag as
// container for the copied elements
// HACK: we exploit the fact that this element has an id with a
// specific format, e.g., id="docs-internal-guid-5bea85da-43dc-fb06-e327-00c1c6576cf7"
const bold = body.find('b')
if (bold && /^docs-internal/.exec(bold.id)) {
body = bold
}
// transformations to turn formatations encoded via styles
// into semantic HTML tags
body.findAll('span').forEach(span => {
// Google Docs uses spans with inline styles
// insted of inline nodes
// We are scanning each span for certain inline styles:
// font-weight: 700 -> <b>
// font-style: italic -> <i>
// vertical-align: super -> <sup>
// vertical-align: sub -> <sub>
// TODO: improve the result for other editors by fusing adjacent annotations of the same type
const nodeTypes = []
if (span.getStyle('font-weight') === '700') nodeTypes.push('b')
if (span.getStyle('font-style') === 'italic') nodeTypes.push('i')
if (span.getStyle('vertical-align') === 'super') nodeTypes.push('sup')
if (span.getStyle('vertical-align') === 'sub') nodeTypes.push('sub')
// remove the style so the element becomes cleaner
span.removeAttribute('style')
createInlineNodes(span.getParent(), true)
function createInlineNodes (parentEl, isRoot) {
if (nodeTypes.length > 0) {
const el = parentEl.createElement(nodeTypes[0])
if (nodeTypes.length === 1) el.append(span.textContent)
if (isRoot) {
parentEl.replaceChild(span, el)
} else {
parentEl.appendChild(el)
}
nodeTypes.shift()
createInlineNodes(el)
}
}
})
// Union siblings with the same tags, e.g. we are turning
// <b>str</b><b><i>ong</i></b> to <b>str<i>ong</i></b>
const tags = ['b', 'i', 'sup', 'sub']
tags.forEach(tag => {
body.findAll(tag).forEach(el => {
const previousSiblingEl = el.getPreviousSibling()
if (previousSiblingEl && el.tagName === previousSiblingEl.tagName) {
const parentEl = el.getParent()
const newEl = parentEl.createElement(tag)
newEl.setInnerHTML(previousSiblingEl.getInnerHTML() + el.getInnerHTML())
parentEl.replaceChild(el, newEl)
parentEl.removeChild(previousSiblingEl)
}
// Union siblings and child with the same tags, e.g. we are turning
// <i>emph</i><b><i>asis</i></b> to <i>emph<b>asis</b></i>
// Note that at this state children always have the same text content
// e.g. there can't be cases like <b><i>emph</i> asis</b> so we don't treat them
if (previousSiblingEl && previousSiblingEl.tagName && el.getChildCount() > 0 && el.getChildAt(0).tagName === previousSiblingEl.tagName) {
const parentEl = el.getParent()
const childEl = el.getChildAt(0)
const newEl = parentEl.createElement(previousSiblingEl.tagName)
const newChildEl = newEl.createElement(tag)
newChildEl.setTextContent(childEl.textContent)
newEl.appendChild(newChildEl)
parentEl.replaceChild(el, newEl)
}
})
})
return body
}
// if the content only
_wrapIntoParagraph (bodyEl) {
const childNodes = bodyEl.getChildNodes()
let shouldWrap = false
for (let i = 0; i < childNodes.length; i++) {
const c = childNodes[i]
if (c.isTextNode()) {
if (!(/^\s+$/.exec(c.textContent))) {
shouldWrap = true
break
}
} else if (INLINENODES[c.tagName]) {
shouldWrap = true
break
}
}
if (shouldWrap) {
const p = bodyEl.createElement('p')
p.append(childNodes)
bodyEl.append(p)
}
return bodyEl
}
}