Skip to content

Commit 0366c37

Browse files
authored
Improve rich text component (#13858)
1 parent a5a0a71 commit 0366c37

File tree

12 files changed

+158
-64
lines changed

12 files changed

+158
-64
lines changed

packages/dom/src/stripHTML.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414
* limitations under the License.
1515
*/
1616

17-
const buffer = document.createElement('div');
17+
const parser = new DOMParser();
1818

1919
export default function stripHTML(string: string) {
2020
// @todo: implement a cheaper way to strip markup.
21-
buffer.innerHTML = string;
22-
return buffer.textContent || '';
21+
const doc = parser.parseFromString(string, 'text/html');
22+
return doc.body.textContent || '';
2323
}

packages/element-library/src/text/display.tsx

+8-4
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ import { useEffect, useRef, useMemo } from '@googleforcreators/react';
2222
import { createSolid, type Solid } from '@googleforcreators/patterns';
2323
import { useUnits } from '@googleforcreators/units';
2424
import { useTransformHandler } from '@googleforcreators/transform';
25-
import { getHTMLFormatters, getHTMLInfo } from '@googleforcreators/rich-text';
25+
import {
26+
getHTMLFormatters,
27+
getHTMLInfo,
28+
sanitizeEditorHtml,
29+
} from '@googleforcreators/rich-text';
2630
import { stripHTML } from '@googleforcreators/dom';
2731
import {
2832
getResponsiveBorder,
@@ -314,7 +318,7 @@ function TextDisplay({
314318
borderRadius={borderRadius}
315319
dataToEditorY={dataToEditorY}
316320
dangerouslySetInnerHTML={{
317-
__html: contentWithoutColor,
321+
__html: sanitizeEditorHtml(contentWithoutColor),
318322
}}
319323
/>
320324
</MarginedElement>
@@ -325,7 +329,7 @@ function TextDisplay({
325329
ref={fgRef}
326330
{...props}
327331
dangerouslySetInnerHTML={{
328-
__html: content,
332+
__html: sanitizeEditorHtml(content),
329333
}}
330334
/>
331335
</MarginedElement>
@@ -351,7 +355,7 @@ function TextDisplay({
351355
<FillElement
352356
ref={fgRef as RefObject<HTMLParagraphElement>}
353357
dangerouslySetInnerHTML={{
354-
__html: content,
358+
__html: sanitizeEditorHtml(content),
355359
}}
356360
previewMode={previewMode}
357361
{...props}

packages/element-library/src/text/frame.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ import type {
2626
TextElementFont,
2727
FrameProps,
2828
} from '@googleforcreators/elements';
29-
import { getCaretCharacterOffsetWithin } from '@googleforcreators/rich-text';
29+
import {
30+
getCaretCharacterOffsetWithin,
31+
sanitizeEditorHtml,
32+
} from '@googleforcreators/rich-text';
3033

3134
/**
3235
* Internal dependencies
@@ -166,7 +169,7 @@ function TextFrame({
166169
// See https://github.com/googleforcreators/web-stories-wp/issues/7745.
167170
data-fix-caret
168171
className="syncMargin"
169-
dangerouslySetInnerHTML={{ __html: content }}
172+
dangerouslySetInnerHTML={{ __html: sanitizeEditorHtml(content) }}
170173
{...props}
171174
/>
172175
);

packages/rich-text/src/formatters/color.ts

+1-13
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@
2020
import {
2121
createSolid,
2222
generatePatternStyles,
23-
getHexFromSolid,
24-
getSolidFromHex,
2523
isPatternEqual,
2624
createSolidFromString,
2725
} from '@googleforcreators/patterns';
@@ -37,17 +35,7 @@ import {
3735
togglePrefixStyle,
3836
getPrefixStylesInSelection,
3937
} from '../styleManipulation';
40-
import { isStyle, getVariable } from './util';
41-
42-
/*
43-
* Color uses PREFIX-XXXXXXXX where XXXXXXXX is the 8 digit
44-
* hex representation of the RGBA color.
45-
*/
46-
const styleToColor = (style: string): Pattern =>
47-
getSolidFromHex(getVariable(style, COLOR));
48-
49-
const colorToStyle = (color: Solid): string =>
50-
`${COLOR}-${getHexFromSolid(color)}`;
38+
import { isStyle, styleToColor, colorToStyle } from './util';
5139

5240
function elementToStyle(element: HTMLElement): string | null {
5341
const isSpan = element.tagName.toLowerCase() === 'span';

packages/rich-text/src/formatters/gradientColor.ts

+5-8
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import {
2121
createSolid,
2222
generatePatternStyles,
23-
getGradientStyleFromColor,
2423
isPatternEqual,
2524
getColorFromGradientStyle,
2625
type Gradient,
@@ -38,13 +37,11 @@ import {
3837
togglePrefixStyle,
3938
getPrefixStylesInSelection,
4039
} from '../styleManipulation';
41-
import { isStyle, getVariable } from './util';
42-
43-
const styleToColor = (style: string): Gradient =>
44-
getColorFromGradientStyle(getVariable(style, GRADIENT_COLOR));
45-
46-
const colorToStyle = (color: Gradient): string =>
47-
`${GRADIENT_COLOR}-${getGradientStyleFromColor(color)}`;
40+
import {
41+
isStyle,
42+
styleToGradientColor as styleToColor,
43+
gradientColorToStyle as colorToStyle,
44+
} from './util';
4845

4946
function elementToStyle(element: HTMLElement): string | null {
5047
const isSpan = element.tagName.toLowerCase() === 'span';

packages/rich-text/src/formatters/util.ts

+31-1
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,25 @@
1818
* External dependencies
1919
*/
2020
import type { FontWeight, FontVariantStyle } from '@googleforcreators/elements';
21+
import {
22+
getColorFromGradientStyle,
23+
getGradientStyleFromColor,
24+
getHexFromSolid,
25+
getSolidFromHex,
26+
type Gradient,
27+
type Pattern,
28+
type Solid,
29+
} from '@googleforcreators/patterns';
2130

2231
/**
2332
* Internal dependencies
2433
*/
25-
import { type LETTERSPACING, WEIGHT } from '../customConstants';
34+
import {
35+
COLOR,
36+
GRADIENT_COLOR,
37+
type LETTERSPACING,
38+
WEIGHT,
39+
} from '../customConstants';
2640

2741
export const isStyle = (style: string | undefined, prefix: string) =>
2842
Boolean(style?.startsWith(prefix));
@@ -60,3 +74,19 @@ export function styleToNumeric(
6074
export function weightToStyle(weight: number) {
6175
return numericToStyle(WEIGHT, weight);
6276
}
77+
78+
/*
79+
* Color uses PREFIX-XXXXXXXX where XXXXXXXX is the 8 digit
80+
* hex representation of the RGBA color.
81+
*/
82+
export const styleToColor = (style: string): Pattern =>
83+
getSolidFromHex(getVariable(style, COLOR));
84+
85+
export const colorToStyle = (color: Solid): string =>
86+
`${COLOR}-${getHexFromSolid(color)}`;
87+
88+
export const styleToGradientColor = (style: string): Gradient =>
89+
getColorFromGradientStyle(getVariable(style, GRADIENT_COLOR));
90+
91+
export const gradientColorToStyle = (color: Gradient): string =>
92+
`${GRADIENT_COLOR}-${getGradientStyleFromColor(color)}`;
+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright 2024 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* External dependencies
19+
*/
20+
import {
21+
createSolid,
22+
getHexFromSolid,
23+
type Solid,
24+
} from '@googleforcreators/patterns';
25+
26+
/**
27+
* Internal dependencies
28+
*/
29+
import { COLOR, NONE } from './customConstants';
30+
import { getSelectAllStateFromHTML } from './htmlManipulation';
31+
import { getPrefixStylesInSelection } from './styleManipulation';
32+
import { styleToColor } from './formatters/util';
33+
34+
export default function getTextColors(html: string): string[] {
35+
const htmlState = getSelectAllStateFromHTML(html);
36+
return getPrefixStylesInSelection(htmlState, COLOR)
37+
.map((color) => {
38+
if (color === NONE) {
39+
return createSolid(0, 0, 0);
40+
}
41+
42+
return styleToColor(color) as Solid;
43+
})
44+
.map(
45+
// To remove the alpha channel.
46+
(color) => '#' + getHexFromSolid(color).slice(0, 6)
47+
);
48+
}

packages/rich-text/src/htmlManipulation.ts

+41-2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
* External dependencies
1919
*/
2020
import { EditorState } from 'draft-js';
21+
import { filterEditorState } from 'draftjs-filters';
2122

2223
/**
2324
* Internal dependencies
@@ -28,6 +29,16 @@ import customImport from './customImport';
2829
import customExport from './customExport';
2930
import { getSelectionForAll } from './util';
3031
import type { StyleSetter, AllowedSetterArgs } from './types';
32+
import {
33+
ITALIC,
34+
UNDERLINE,
35+
WEIGHT,
36+
COLOR,
37+
LETTERSPACING,
38+
UPPERCASE,
39+
GRADIENT_COLOR,
40+
} from './customConstants';
41+
import { getPrefixStylesInSelection } from './styleManipulation';
3142

3243
/**
3344
* Return an editor state object with content set to parsed HTML
@@ -60,8 +71,7 @@ function updateAndReturnHTML(
6071
...args: [AllowedSetterArgs]
6172
) {
6273
const stateWithUpdate = updater(getSelectAllStateFromHTML(html), ...args);
63-
const renderedHTML = customExport(stateWithUpdate);
64-
return renderedHTML;
74+
return customExport(stateWithUpdate);
6575
}
6676

6777
const getHTMLFormatter =
@@ -90,3 +100,32 @@ export function getHTMLInfo(html: string) {
90100
const htmlStateInfo = getStateInfo(getSelectAllStateFromHTML(html));
91101
return htmlStateInfo;
92102
}
103+
104+
export function sanitizeEditorHtml(html: string) {
105+
const editorState = getSelectAllStateFromHTML(html);
106+
107+
const styles: string[] = [
108+
...getPrefixStylesInSelection(editorState, ITALIC),
109+
...getPrefixStylesInSelection(editorState, UNDERLINE),
110+
...getPrefixStylesInSelection(editorState, WEIGHT),
111+
...getPrefixStylesInSelection(editorState, COLOR),
112+
...getPrefixStylesInSelection(editorState, LETTERSPACING),
113+
...getPrefixStylesInSelection(editorState, UPPERCASE),
114+
...getPrefixStylesInSelection(editorState, GRADIENT_COLOR),
115+
];
116+
117+
return (
118+
customExport(
119+
filterEditorState(
120+
{
121+
blocks: [],
122+
styles,
123+
entities: [],
124+
maxNesting: 1,
125+
whitespacedCharacters: [],
126+
},
127+
editorState
128+
)
129+
) || ''
130+
);
131+
}

packages/rich-text/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export { default as RichTextContext } from './context';
2323
export { default as useRichText } from './useRichText';
2424
export { default as usePasteTextContent } from './usePasteTextContent';
2525
export { default as getFontVariants } from './getFontVariants';
26+
export { default as getTextColors } from './getTextColors';
2627
export { default as getCaretCharacterOffsetWithin } from './utils/getCaretCharacterOffsetWithin';
2728
export * from './htmlManipulation';
2829

Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2021 Google LLC
2+
* Copyright 2020 Google LLC
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -14,22 +14,19 @@
1414
* limitations under the License.
1515
*/
1616

17-
let spansFromContentBuffer;
1817
/**
19-
*
20-
* @param {string} content the buffer containing text element content
21-
* @return {Array} list of individual span elements from the content
18+
* Internal dependencies
2219
*/
23-
export function getSpansFromContent(content) {
24-
// memoize buffer
25-
if (!spansFromContentBuffer) {
26-
spansFromContentBuffer = document.createElement('div');
27-
}
20+
import getTextColors from '../getTextColors';
21+
22+
describe('getTextColors', () => {
23+
it('should return a list of text colors', () => {
24+
const htmlContent =
25+
'Fill in <span style="color: #eb0404">some</span> <span style="color: #026111">text</span>';
26+
const expected = ['#000000', '#eb0404', '#026111'];
2827

29-
spansFromContentBuffer.innerHTML = content;
28+
const actual = getTextColors(htmlContent);
3029

31-
// return Array instead of HtmlCollection
32-
return Array.prototype.slice.call(
33-
spansFromContentBuffer.getElementsByTagName('span')
34-
);
35-
}
30+
expect(actual).toStrictEqual(expected);
31+
});
32+
});

packages/story-editor/src/components/checklist/checks/pageBackgroundLowTextContrast/check.js

+2-14
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
preloadImage,
2828
} from '@googleforcreators/media';
2929
import { createSolidFromString } from '@googleforcreators/patterns';
30+
import { getTextColors } from '@googleforcreators/rich-text';
3031

3132
/**
3233
* Internal dependencies
@@ -36,7 +37,6 @@ import {
3637
calculateLuminanceFromStyleColor,
3738
checkContrastFromLuminances,
3839
} from '../../../../utils/contrastUtils';
39-
import { getSpansFromContent } from '../../utils';
4040
import getMediaBaseColor from '../../../../utils/getMediaBaseColor';
4141
import { noop } from '../../../../utils/noop';
4242

@@ -276,19 +276,7 @@ async function getOverlapBgColor({ bgImage, bgBox, overlapBox }) {
276276
* @return {Array} the style colors from the span tags in text element content
277277
*/
278278
function getTextStyleColors(element) {
279-
const spans = getSpansFromContent(element.content);
280-
const textStyleColors = spans
281-
.map((span) => span.style?.color)
282-
.filter(Boolean);
283-
// if no colors were retrieved but there are spans, there is a black default color
284-
const noColorStyleOnSpans =
285-
textStyleColors.length === 0 && spans.length !== 0;
286-
// if no spans were retrieved but there is content, there is a black default color
287-
const noSpans = element.content.length !== 0 && spans.length === 0;
288-
if (noColorStyleOnSpans || noSpans) {
289-
textStyleColors.push('rgb(0, 0, 0)');
290-
}
291-
return textStyleColors;
279+
return getTextColors(element.content);
292280
}
293281

294282
function getTextShapeBackgroundColor({ background }) {

packages/story-editor/src/components/checklist/utils/index.js

-1
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,4 @@ export { characterCountForPage } from './characterCountForPage';
1818
export { filterStoryPages } from './filterStoryPages';
1919
export { filterStoryElements } from './filterStoryElements';
2020
export { getVisibleThumbnails } from './getVisibleThumbnails';
21-
export { getSpansFromContent } from './getSpansFromContent';
2221
export { ThumbnailPagePreview } from './thumbnailPagePreview';

0 commit comments

Comments
 (0)