Skip to content

Commit

Permalink
fix(clipboard): filter plaintext on paste
Browse files Browse the repository at this point in the history
  • Loading branch information
peyerluk authored and dfreier committed Sep 19, 2023
1 parent ed139eb commit 82a67b0
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 21 deletions.
43 changes: 43 additions & 0 deletions spec/clipboard.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ describe('Clipboard', function () {
return parseContent(div)
}

function extractPlainText (str) {
const div = document.createElement('div')
div.innerHTML = str
return parseContent(div, {plainText: true})
}

function extractSingleBlock (str) {
return extract(str)[0]
}
Expand Down Expand Up @@ -377,5 +383,42 @@ describe('Clipboard', function () {
expect(block).to.equal('text outside “<a href="https://livingdocs.io">text inside</a>”')
})
})

// Plain Text
// ----------

describe('plain text option', function () {
it('unwraps a single <b>', function () {
expect(extractPlainText('<b>a</b>')[0]).to.equal('a')
})

it('unwraps a single <strong>', function () {
expect(extractPlainText('<strong>a</strong>')[0]).to.equal('a')
})

it('unwraps nested <b><strong>', function () {
expect(extractPlainText('<b><strong>a</strong></b>')[0]).to.equal('a')
})

it('unwraps nested <span><strong>', function () {
expect(extractPlainText('<span><strong>a</strong></span>')[0]).to.equal('a')
})

it('keeps <br> tags', function () {
expect(extractPlainText('a<br>b')[0]).to.equal('a<br>b')
})

it('creates two blocks from two paragraphs', function () {
const blocks = extractPlainText('<p>a</p><p>b</p>')
expect(blocks[0]).to.equal('a')
expect(blocks[1]).to.equal('b')
})

it('unwraps phrasing tags within blocks', function () {
const blocks = extractPlainText('<p><i>a</i></p><p><em>b</em></p>')
expect(blocks[0]).to.equal('a')
expect(blocks[1]).to.equal('b')
})
})
})
})
50 changes: 29 additions & 21 deletions src/clipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import config from './config'
import * as string from './util/string'
import * as nodeType from './node-type'
import * as quotes from './quotes'
import {isPlainTextBlock} from './block'

let allowedElements, requiredAttributes, transformElements, blockLevelElements, replaceQuotes
let allowedElements, allowedPlainTextElements, requiredAttributes, transformElements, blockLevelElements, replaceQuotes
let splitIntoBlocks, blacklistedElements
const whitespaceOnly = /^\s*$/
const blockPlaceholder = '<!-- BLOCK -->'
Expand All @@ -13,6 +14,7 @@ updateConfig(config)
export function updateConfig (conf) {
const rules = conf.pastedHtmlRules
allowedElements = rules.allowedElements || {}
allowedPlainTextElements = rules.allowedPlainTextElements || {}
requiredAttributes = rules.requiredAttributes || {}
transformElements = rules.transformElements || {}
blacklistedElements = rules.blacklistedElements || []
Expand All @@ -25,9 +27,9 @@ export function updateConfig (conf) {
rules.splitIntoBlocks.forEach((name) => { splitIntoBlocks[name] = true })
}

export function paste (element, cursor, clipboardContent) {
const document = element.ownerDocument
element.setAttribute(config.pastingAttribute, true)
export function paste (block, cursor, clipboardContent) {
const document = block.ownerDocument
block.setAttribute(config.pastingAttribute, true)

if (cursor.isSelection) {
cursor = cursor.deleteExactSurroundingTags()
Expand All @@ -39,9 +41,10 @@ export function paste (element, cursor, clipboardContent) {
const pasteHolder = document.createElement('div')
pasteHolder.innerHTML = clipboardContent

const blocks = parseContent(pasteHolder)
const isPlainText = isPlainTextBlock(block)
const blocks = parseContent(pasteHolder, {plainText: isPlainText})

element.removeAttribute(config.pastingAttribute)
block.removeAttribute(config.pastingAttribute)
return {blocks, cursor}
}

Expand All @@ -55,30 +58,35 @@ export function paste (element, cursor, clipboardContent) {
* @param {DOM node} A container where the pasted content is located.
* @returns {Array of Strings} An array of cleaned innerHTML like strings.
*/
export function parseContent (element) {
export function parseContent (element, {plainText = false} = {}) {
const options = {
allowedElements: plainText ? allowedPlainTextElements : allowedElements,
keepInternalRelativeLinks: plainText ? false : keepInternalRelativeLinks
}

// Filter pasted content
return filterHtmlElements(element)
return filterHtmlElements(element, options)
// Handle Blocks
.split(blockPlaceholder)
.map((entry) => string.trim(cleanWhitespace(replaceAllQuotes(entry))))
.filter((entry) => !whitespaceOnly.test(entry))
}

export function filterHtmlElements (elem) {
function filterHtmlElements (elem, options) {
return Array.from(elem.childNodes).reduce((content, child) => {
if (blacklistedElements.indexOf(child.nodeName.toLowerCase()) !== -1) {
return ''
}

// Keep internal relative links relative (on paste).
if (keepInternalRelativeLinks && child.nodeName === 'A' && child.href) {
if (options.keepInternalRelativeLinks && child.nodeName === 'A' && child.href) {
const stripInternalHost = child.getAttribute('href').replace(window.location.origin, '')
child.setAttribute('href', stripInternalHost)
}

if (child.nodeType === nodeType.elementNode) {
const childContent = filterHtmlElements(child)
return content + conditionalNodeWrap(child, childContent)
const childContent = filterHtmlElements(child, options)
return content + conditionalNodeWrap(child, childContent, options)
}

// Escape HTML characters <, > and &
Expand All @@ -87,11 +95,11 @@ export function filterHtmlElements (elem) {
}, '')
}

export function conditionalNodeWrap (child, content) {
function conditionalNodeWrap (child, content, options) {
let nodeName = child.nodeName.toLowerCase()
nodeName = transformNodeName(nodeName)

if (shouldKeepNode(nodeName, child)) {
if (shouldKeepNode(nodeName, child, options)) {
const attributes = filterAttributes(nodeName, child)

if (nodeName === 'br') return `<${nodeName + attributes}>`
Expand All @@ -115,7 +123,7 @@ export function conditionalNodeWrap (child, content) {
}

// returns string of concatenated attributes e.g. 'target="_blank" rel="nofollow" href="/test.com"'
export function filterAttributes (nodeName, node) {
function filterAttributes (nodeName, node) {
return Array.from(node.attributes).reduce((attributes, {name, value}) => {
if (allowedElements[nodeName][name] && value) {
return `${attributes} ${name}="${value}"`
Expand All @@ -124,22 +132,22 @@ export function filterAttributes (nodeName, node) {
}, '')
}

export function transformNodeName (nodeName) {
function transformNodeName (nodeName) {
return transformElements[nodeName] || nodeName
}

export function hasRequiredAttributes (nodeName, node) {
function hasRequiredAttributes (nodeName, node) {
const requiredAttrs = requiredAttributes[nodeName]
if (!requiredAttrs) return true

return !requiredAttrs.some((name) => !node.getAttribute(name))
}

export function shouldKeepNode (nodeName, node) {
return allowedElements[nodeName] && hasRequiredAttributes(nodeName, node)
function shouldKeepNode (nodeName, node, options) {
return options.allowedElements[nodeName] && hasRequiredAttributes(nodeName, node)
}

export function cleanWhitespace (str) {
function cleanWhitespace (str) {
return str
.replace(/\n/g, ' ')
.replace(/ {2,}/g, ' ')
Expand All @@ -149,7 +157,7 @@ export function cleanWhitespace (str) {
))
}

export function replaceAllQuotes (str) {
function replaceAllQuotes (str) {
if (replaceQuotes.quotes || replaceQuotes.singleQuotes || replaceQuotes.apostrophe) {
return quotes.replaceAllQuotes(str, replaceQuotes)
}
Expand Down
3 changes: 3 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export default {
'em': {},
'br': {}
},
allowedPlainTextElements: {
'br': {}
},

// Elements that have required attributes.
// If these are not present the elements are filtered out.
Expand Down

0 comments on commit 82a67b0

Please sign in to comment.