Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Retain text color when pasting #27399

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions packages/blocks/src/api/raw-handling/paste-handler.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
/**
* External dependencies
*/
import { flatMap, filter, compact } from 'lodash';
import { flatMap, filter, compact, identity } from 'lodash';

/**
* WordPress dependencies
*/
import { getPhrasingContentSchema, removeInvalidHTML } from '@wordpress/dom';
import {
getPhrasingContentSchema,
alterSpecialTags,
removeInvalidHTML,
} from '@wordpress/dom';

/**
* Internal dependencies
Expand Down Expand Up @@ -40,6 +44,43 @@ import emptyParagraphRemover from './empty-paragraph-remover';
*/
const { console } = window;

const difference = ( base ) => base;

const getStylesEntries = ( stylesString ) =>
stylesString
.split( ';' )
.filter( identity )
.map( ( styles ) => {
const [ key, values ] = styles.trim().split( ':' );
const valuesArr =
values &&
values
.split( `'` )
.join( '' )
.split( '"' )
.join( '"' )
.split( ', ' )
.map( ( w ) => w.trim() )
.join( '' );

return [ key, valuesArr ];
} );

const defaultStyles =
"color: rgb(40, 48, 61); font-family: -apple-system, system-ui, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; font-size: 20px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: pre-wrap; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(209, 228, 221); text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;";

const toString = ( arr ) =>
arr.map( ( pair ) => pair.join( ':' ) ).join( '; ' );

const sanitize = ( x ) => x;
const removeDefaultValues = ( html ) => {
const entriesForDefaultStyles = getStylesEntries( defaultStyles );
const [ htmlPart0, restHtml ] = html.split( 'style=' );
const [ , htmlStyles, htmlPart2 ] = restHtml.split( `"` );
const entriesForCurrentStyles = getStylesEntries( htmlStyles );
const diff = difference( entriesForDefaultStyles, entriesForCurrentStyles );
return htmlPart0 + 'style="' + toString( diff ) + '"' + htmlPart2;
};
/**
* Filters HTML to only contain phrasing content.
*
Expand All @@ -54,9 +95,13 @@ function filterInlineHTML( HTML, preserveWhiteSpace ) {
phrasingContentReducer,
commentRemover,
] );
HTML = removeInvalidHTML( HTML, getPhrasingContentSchema( 'paste' ), {
inline: true,
} );

HTML = sanitize( removeDefaultValues( HTML ) );
// HTML = removeInvalidHTML( HTML, getPhrasingContentSchema( 'paste' ), {
// inline: true,
// } );

HTML = alterSpecialTags( HTML );

if ( ! preserveWhiteSpace ) {
HTML = deepFilterHTML( HTML, [ htmlFormattingRemover, brRemover ] );
Expand Down Expand Up @@ -117,7 +162,6 @@ function htmlToBlocks( { html, rawTransforms } ) {
if ( transform ) {
return transform( node );
}

return createBlock(
blockName,
getBlockAttributes( blockName, node.outerHTML )
Expand Down
113 changes: 66 additions & 47 deletions packages/blocks/src/api/raw-handling/phrasing-content-reducer.js
Original file line number Diff line number Diff line change
@@ -1,60 +1,79 @@
/**
* External dependencies
*/
import { includes } from 'lodash';
import { identity, includes } from 'lodash';

/**
* WordPress dependencies
*/
import { wrap, replaceTag } from '@wordpress/dom';

const getSemanticTags = ( { node } ) => {
const {
fontWeight,
fontStyle,
textDecorationLine,
textDecoration,
verticalAlign,
} = node.style;
const tags = [];
if ( fontWeight === 'bold' || fontWeight === '700' ) {
tags.push( 'strong' );
}

if ( fontStyle === 'italic' ) {
tags.push( 'em' );
}

// Some DOM implementations (Safari, JSDom) don't support
// style.textDecorationLine, so we check style.textDecoration as a
// fallback.
if (
textDecorationLine === 'line-through' ||
includes( textDecoration, 'line-through' )
) {
tags.push( 's' );
}

if ( verticalAlign === 'super' ) {
tags.push( 'sup' );
} else if ( verticalAlign === 'sub' ) {
tags.push( 'sub' );
}
return tags;
};

const handleSpan = ( { node, doc } ) => {
const semanticTagsToWrap = getSemanticTags( node );
semanticTagsToWrap.forEach( ( tag ) =>
wrap( doc.createElement( tag ), node )
);
};
const handleBold = ( { node } ) => replaceTag( node, 'strong' );
const handleItalic = ( { node } ) => replaceTag( node, 'italic' );
const handleAnchor = ( { node } ) => {
// In jsdom-jscore, 'node.target' can be null.
// TODO: Explore fixing this by patching jsdom-jscore.
if ( node.target && node.target.toLowerCase() === '_blank' ) {
node.rel = 'noreferrer noopener';
} else {
node.removeAttribute( 'target' );
node.removeAttribute( 'rel' );
}
};

const elementNameToHandlerMapper = {
span: handleSpan,
B: handleBold,
I: handleItalic,
A: handleAnchor,
};

export default function phrasingContentReducer( node, doc ) {
// In jsdom-jscore, 'node.style' can be null.
// TODO: Explore fixing this by patching jsdom-jscore.
if ( node.nodeName === 'SPAN' && node.style ) {
const {
fontWeight,
fontStyle,
textDecorationLine,
textDecoration,
verticalAlign,
} = node.style;

if ( fontWeight === 'bold' || fontWeight === '700' ) {
wrap( doc.createElement( 'strong' ), node );
}

if ( fontStyle === 'italic' ) {
wrap( doc.createElement( 'em' ), node );
}

// Some DOM implementations (Safari, JSDom) don't support
// style.textDecorationLine, so we check style.textDecoration as a
// fallback.
if (
textDecorationLine === 'line-through' ||
includes( textDecoration, 'line-through' )
) {
wrap( doc.createElement( 's' ), node );
}

if ( verticalAlign === 'super' ) {
wrap( doc.createElement( 'sup' ), node );
} else if ( verticalAlign === 'sub' ) {
wrap( doc.createElement( 'sub' ), node );
}
} else if ( node.nodeName === 'B' ) {
node = replaceTag( node, 'strong' );
} else if ( node.nodeName === 'I' ) {
node = replaceTag( node, 'em' );
} else if ( node.nodeName === 'A' ) {
// In jsdom-jscore, 'node.target' can be null.
// TODO: Explore fixing this by patching jsdom-jscore.
if ( node.target && node.target.toLowerCase() === '_blank' ) {
node.rel = 'noreferrer noopener';
} else {
node.removeAttribute( 'target' );
node.removeAttribute( 'rel' );
}
}
const elementType = node.nodeName;
const elementHandler =
elementNameToHandlerMapper[ elementType ] || identity;
elementHandler( { node, doc } );
}
8 changes: 8 additions & 0 deletions packages/dom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ npm install @wordpress/dom --save

<!-- START TOKEN(Autogenerated API docs) -->

<a name="alterSpecialTags" href="#alterSpecialTags">#</a> **alterSpecialTags**

Undocumented declaration.

<a name="computeCaretRect" href="#computeCaretRect">#</a> **computeCaretRect**

Get the rectangle for the selection in a container.
Expand Down Expand Up @@ -304,6 +308,10 @@ _Returns_

- `string`: The cleaned up HTML.

<a name="removeSpecialTags" href="#removeSpecialTags">#</a> **removeSpecialTags**

Undocumented declaration.

<a name="replace" href="#replace">#</a> **replace**

Given two DOM nodes, replaces the former with the latter in the DOM.
Expand Down
34 changes: 34 additions & 0 deletions packages/dom/src/phrasing-content.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ import { omit, without } from 'lodash';
* @see https://www.w3.org/TR/2011/WD-html5-20110525/content-models.html#phrasing-content-0
*/

/**
* @type {{coloured: {attributes: [string]}}}
* Some spans will carry additional styling that needs to be preserved.
* In order not to loose information about styling (as all spans are removed) a special tags are introduced, wich later will be turned back to span.
*/
const specialTags = {
coloured: { attributes: [ 'style' ], originalTag: 'span' },
};

/**
* All text-level semantic elements.
*
Expand Down Expand Up @@ -47,8 +56,33 @@ const textContentSchema = {
bdo: { attributes: [ 'dir' ] },
wbr: {},
'#text': {},
...specialTags,
};

export const alterSpecialTags = ( HTML ) =>
Object.entries( specialTags ).reduce( ( acc, [ tag, { originalTag } ] ) => {
const openingTagRegExp = new RegExp( `<${ tag }`, 'g' );
const closingTagRegExp = new RegExp( `</${ tag }`, 'g' );

const withOriginalOpeningTag = HTML.replace(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would walk the tree instead of relying on regexps - they may have unintended consequences.

Copy link
Contributor Author

@grzim grzim Dec 1, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All manually placed special chars like < have escape characters so this should not be dangerous, nevertheless, indeed tree traversing is just safe, so it is better to approach, thanks.
I am just not convinced with the entire idea - as I've mentioned in the desc - this is not scalable and there would be needed a huge amount of logic to cover all cases (colors are just a tip of an iceberg). I think it will be better to change the mechanics of unfolding letters from their corresponding spans and the way spans are translated to native html tags.

openingTagRegExp,
`<${ originalTag }`
);
return withOriginalOpeningTag.replace(
closingTagRegExp,
`</${ originalTag }`
);
}, HTML );

export const removeSpecialTags = ( HTML ) =>
Object.entries( specialTags ).reduce( ( acc, [ tag ] ) => {
const openingTagRegExp = new RegExp( `<${ tag }`, 'g' );
const closingTagRegExp = new RegExp( `</${ tag }`, 'g' );

const withOriginalOpeningTag = HTML.replace( openingTagRegExp, `` );
return withOriginalOpeningTag.replace( closingTagRegExp, `` );
}, HTML );

// Recursion is needed.
// Possible: strong > em > strong.
// Impossible: strong > strong.
Expand Down