Skip to content

Commit

Permalink
Lexical: Updated toolbar & text node exporting
Browse files Browse the repository at this point in the history
- Updated toolbar to match existing editor, including dynamic RTL/LTR
  controls.
- Updated text node handling to not include spans and extra classes when
  not needed. Added & update tests to cover.
  • Loading branch information
ssddanbrown committed Sep 23, 2024
1 parent 8b32e6c commit a62d838
Show file tree
Hide file tree
Showing 16 changed files with 152 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ describe('HTMLCopyAndPaste tests', () => {
pastedHTML: ` <span>123<div>456</div></span>`,
},
{
expectedHTML: `<ul><li role="checkbox" tabindex="-1" aria-checked="true" value="1"><span data-lexical-text="true">done</span></li><li role="checkbox" tabindex="-1" aria-checked="false" value="2"><span data-lexical-text="true">todo</span></li><li value="3"><ul><li role="checkbox" tabindex="-1" aria-checked="true" value="1"><span data-lexical-text="true">done</span></li><li role="checkbox" tabindex="-1" aria-checked="false" value="2"><span data-lexical-text="true">todo</span></li></ul></li><li role="checkbox" tabindex="-1" aria-checked="false" value="3"><span data-lexical-text="true">todo</span></li></ul>`,
expectedHTML: `<ul><li role="checkbox" tabindex="-1" aria-checked="true" value="1"><span style="color: rgb(0, 0, 0);" data-lexical-text="true">done</span></li><li role="checkbox" tabindex="-1" aria-checked="false" value="2"><span style="color: rgb(0, 0, 0);" data-lexical-text="true">todo</span></li><li value="3"><ul><li role="checkbox" tabindex="-1" aria-checked="true" value="1"><span style="color: rgb(0, 0, 0);" data-lexical-text="true">done</span></li><li role="checkbox" tabindex="-1" aria-checked="false" value="2"><span style="color: rgb(0, 0, 0);" data-lexical-text="true">todo</span></li></ul></li><li role="checkbox" tabindex="-1" aria-checked="false" value="3"><span style="color: rgb(0, 0, 0);" data-lexical-text="true">todo</span></li></ul>`,
name: 'google doc checklist',
pastedHTML: `<meta charset='utf-8'><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-1980f960-7fff-f4df-4ba3-26c6e1508542"><ul style="margin-top:0;margin-bottom:0;padding-inline-start:28px;"><li role="checkbox" aria-checked="true" style="list-style-type:none;font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:line-through;-webkit-text-decoration-skip:none;text-decoration-skip-ink:none;vertical-align:baseline;white-space:pre;" aria-level="1"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAABbElEQVR4Ae3bsU4CYRDEcRsxodZE8Q0BbS258l5MwESJNL6HOfrPKdhyxeBcwk5mkn9F98sGIOSuPM/zPM/zPI+xG/SEtuiAWpEOaIOWaDIWziP6RK14OzSjX44ITvTBvqRn1MRaMIHeBIE2TKBBEGhgArWkKmtJBjKQgQxkIANd/Aw0NVC+O7RHvYFynHasN1COE/UGynGiXgOIjxOtdIH4OGJAfBwxID6OGBAfRwiIjyMARMCpCjRF5+72Dzhd5R+rHfpC92NeTlWgLl5PkQg4RYBynBSJgFMGKMNJkQg4lYFeUDuFRMCpBXQOEgGnDtA/kPg4xT7m2y/tCd9zKgOdviTC5RQEIiAFjh4QASlw9IAISIEjCURAWvmf1UDKcQwUSDmOgWLdMcxA7BnIQAYykIEM5EcRvplAW0GgNRNoKQg0ZwJN0E4I5x1dI+pmgSSA84BG2QQt0LrYG/eAXtGccjme53me53me9wPjPWZWjhktAQAAAABJRU5ErkJggg==" width="18.4px" height="18.4px" alt="checked" aria-roledescription="checkbox" style="margin-right:3px;" /><p style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;display:inline-block;vertical-align:top;margin-top:0;" role="presentation"><span style="font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:line-through;-webkit-text-decoration-skip:none;text-decoration-skip-ink:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">done</span></p></li><li role="checkbox" aria-checked="false" style="list-style-type:none;font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="1"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAA1ElEQVR4Ae3bMQ4BURSFYY2xBuwQ7BIkTGxFRj9Oo9RdkXn5TvL3L19u+2ZmZmZmZhVbpH26pFcaJ9IrndMudb/CWadHGiden1bll9MIzqd79SUd0thY20qga4NA50qgoUGgoRJo/NL/V/N+QIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIEyFeEZyXQpUGgUyXQrkGgTSVQl/qGcG5pnkq3Sn0jOMv0k3Vpm05pmNjfsGPalFyOmZmZmdkbSS9cKbtzhxMAAAAASUVORK5CYII=" width="18.4px" height="18.4px" alt="unchecked" aria-roledescription="checkbox" style="margin-right:3px;" /><p style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;display:inline-block;vertical-align:top;margin-top:0;" role="presentation"><span style="font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">todo</span></p></li><ul style="margin-top:0;margin-bottom:0;padding-inline-start:28px;"><li role="checkbox" aria-checked="true" style="list-style-type:none;font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:line-through;-webkit-text-decoration-skip:none;text-decoration-skip-ink:none;vertical-align:baseline;white-space:pre;" aria-level="2"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAABbElEQVR4Ae3bsU4CYRDEcRsxodZE8Q0BbS258l5MwESJNL6HOfrPKdhyxeBcwk5mkn9F98sGIOSuPM/zPM/zPI+xG/SEtuiAWpEOaIOWaDIWziP6RK14OzSjX44ITvTBvqRn1MRaMIHeBIE2TKBBEGhgArWkKmtJBjKQgQxkIANd/Aw0NVC+O7RHvYFynHasN1COE/UGynGiXgOIjxOtdIH4OGJAfBwxID6OGBAfRwiIjyMARMCpCjRF5+72Dzhd5R+rHfpC92NeTlWgLl5PkQg4RYBynBSJgFMGKMNJkQg4lYFeUDuFRMCpBXQOEgGnDtA/kPg4xT7m2y/tCd9zKgOdviTC5RQEIiAFjh4QASlw9IAISIEjCURAWvmf1UDKcQwUSDmOgWLdMcxA7BnIQAYykIEM5EcRvplAW0GgNRNoKQg0ZwJN0E4I5x1dI+pmgSSA84BG2QQt0LrYG/eAXtGccjme53me53me9wPjPWZWjhktAQAAAABJRU5ErkJggg==" width="18.4px" height="18.4px" alt="checked" aria-roledescription="checkbox" style="margin-right:3px;" /><p style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;display:inline-block;vertical-align:top;margin-top:0;" role="presentation"><span style="font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:line-through;-webkit-text-decoration-skip:none;text-decoration-skip-ink:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">done</span></p></li><li role="checkbox" aria-checked="false" style="list-style-type:none;font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="2"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAA1ElEQVR4Ae3bMQ4BURSFYY2xBuwQ7BIkTGxFRj9Oo9RdkXn5TvL3L19u+2ZmZmZmZhVbpH26pFcaJ9IrndMudb/CWadHGiden1bll9MIzqd79SUd0thY20qga4NA50qgoUGgoRJo/NL/V/N+QIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIEyFeEZyXQpUGgUyXQrkGgTSVQl/qGcG5pnkq3Sn0jOMv0k3Vpm05pmNjfsGPalFyOmZmZmdkbSS9cKbtzhxMAAAAASUVORK5CYII=" width="18.4px" height="18.4px" alt="unchecked" aria-roledescription="checkbox" style="margin-right:3px;" /><p style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;display:inline-block;vertical-align:top;margin-top:0;" role="presentation"><span style="font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">todo</span></p></li></ul><li role="checkbox" aria-checked="false" style="list-style-type:none;font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="1"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAA1ElEQVR4Ae3bMQ4BURSFYY2xBuwQ7BIkTGxFRj9Oo9RdkXn5TvL3L19u+2ZmZmZmZhVbpH26pFcaJ9IrndMudb/CWadHGiden1bll9MIzqd79SUd0thY20qga4NA50qgoUGgoRJo/NL/V/N+QIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIEyFeEZyXQpUGgUyXQrkGgTSVQl/qGcG5pnkq3Sn0jOMv0k3Vpm05pmNjfsGPalFyOmZmZmdkbSS9cKbtzhxMAAAAASUVORK5CYII=" width="18.4px" height="18.4px" alt="unchecked" aria-roledescription="checkbox" style="margin-right:3px;" /><p style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;display:inline-block;vertical-align:top;margin-top:0;" role="presentation"><span style="font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">todo</span></p></li></ul></b>`,
},
Expand Down
41 changes: 39 additions & 2 deletions resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,15 +624,36 @@ export class TextNode extends LexicalNode {
element !== null && isHTMLElement(element),
'Expected TextNode createDOM to always return a HTMLElement',
);
element.style.whiteSpace = 'pre-wrap';

// Wrap up to retain space if head/tail whitespace exists
const text = this.getTextContent();
if (/^\s|\s$/.test(text)) {
element.style.whiteSpace = 'pre-wrap';
}

// Strip editor theme classes
for (const className of Array.from(element.classList.values())) {
if (className.startsWith('editor-theme-')) {
element.classList.remove(className);
}
}
if (element.classList.length === 0) {
element.removeAttribute('class');
}

// Remove placeholder tag if redundant
if (element.nodeName === 'SPAN' && !element.getAttribute('style')) {
element = document.createTextNode(text);
}

// This is the only way to properly add support for most clients,
// even if it's semantically incorrect to have to resort to using
// <b>, <u>, <s>, <i> elements.
if (this.hasFormat('bold')) {
element = wrapElementWith(element, 'b');
}
if (this.hasFormat('italic')) {
element = wrapElementWith(element, 'i');
element = wrapElementWith(element, 'em');
}
if (this.hasFormat('strikethrough')) {
element = wrapElementWith(element, 's');
Expand Down Expand Up @@ -1329,6 +1350,10 @@ function applyTextFormatFromStyle(
// Google Docs uses span tags + vertical-align to specify subscript and superscript
const verticalAlign = style.verticalAlign;

// Styles to copy to node
const color = style.color;
const backgroundColor = style.backgroundColor;

return (lexicalNode: LexicalNode) => {
if (!$isTextNode(lexicalNode)) {
return lexicalNode;
Expand All @@ -1355,6 +1380,18 @@ function applyTextFormatFromStyle(
lexicalNode.toggleFormat('superscript');
}

// Apply styles
let style = lexicalNode.getStyle();
if (color) {
style += `color: ${color};`;
}
if (backgroundColor && backgroundColor !== 'transparent') {
style += `background-color: ${backgroundColor};`;
}
if (style) {
lexicalNode.setStyle(style);
}

if (shouldApply && !lexicalNode.hasFormat(shouldApply)) {
lexicalNode.toggleFormat(shouldApply);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ describe('LexicalTabNode tests', () => {
$insertDataTransferForRichText(dataTransfer, selection, editor);
});
expect(testEnv.innerHTML).toBe(
'<p><span data-lexical-text="true">Hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span></p><p><span data-lexical-text="true">Hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span></p>',
'<p><span style="color: rgb(0, 0, 0);" data-lexical-text="true">Hello</span><span data-lexical-text="true">\t</span><span style="color: rgb(0, 0, 0);" data-lexical-text="true">world</span></p><p><span style="color: rgb(0, 0, 0);" data-lexical-text="true">Hello</span><span data-lexical-text="true">\t</span><span style="color: rgb(0, 0, 0);" data-lexical-text="true">world</span></p>',
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import {
$createParagraphNode,
$createTextNode,
$createTextNode, $getEditor,
$getNodeByKey,
$getRoot,
$getSelection,
Expand Down Expand Up @@ -41,6 +41,9 @@ import {
$setCompositionKey,
getEditorStateTextContent,
} from '../../../LexicalUtils';
import {Text} from "@codemirror/state";
import {$generateHtmlFromNodes} from "@lexical/html";
import {formatBold} from "@lexical/selection/__tests__/utils";

const editorConfig = Object.freeze({
namespace: '',
Expand Down Expand Up @@ -792,6 +795,58 @@ describe('LexicalTextNode tests', () => {
);
});

describe('exportDOM()', () => {

test('simple text exports as a text node', async () => {
await update(() => {
const paragraph = $getRoot().getFirstChild<ElementNode>()!;
const textNode = $createTextNode('hello');
paragraph.append(textNode);

const html = $generateHtmlFromNodes($getEditor(), null);
expect(html).toBe('<p>hello</p>');
});
});

test('simple text wrapped in span if leading or ending spacing', async () => {

const textByExpectedHtml = {
'hello ': '<p><span style="white-space: pre-wrap;">hello </span></p>',
' hello': '<p><span style="white-space: pre-wrap;"> hello</span></p>',
' hello ': '<p><span style="white-space: pre-wrap;"> hello </span></p>',
}

await update(() => {
const paragraph = $getRoot().getFirstChild<ElementNode>()!;
for (const [text, expectedHtml] of Object.entries(textByExpectedHtml)) {
paragraph.getChildren().forEach(c => c.remove(true));
const textNode = $createTextNode(text);
paragraph.append(textNode);

const html = $generateHtmlFromNodes($getEditor(), null);
expect(html).toBe(expectedHtml);
}
});
});

test('text with formats exports using format elements instead of classes', async () => {
await update(() => {
const paragraph = $getRoot().getFirstChild<ElementNode>()!;
const textNode = $createTextNode('hello');
textNode.toggleFormat('bold');
textNode.toggleFormat('subscript');
textNode.toggleFormat('italic');
textNode.toggleFormat('underline');
textNode.toggleFormat('code');
paragraph.append(textNode);

const html = $generateHtmlFromNodes($getEditor(), null);
expect(html).toBe('<p><u><em><b><code spellcheck="false"><strong>hello</strong></code></b></em></u></p>');
});
});

});

test('mergeWithSibling', async () => {
await update(() => {
const paragraph = $getRoot().getFirstChild<ElementNode>()!;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ describe('LexicalHeadlessEditor', () => {
cleanup();

expect(html).toBe(
'<p dir="ltr"><span style="white-space: pre-wrap;">hello world</span></p>',
'<p>hello world</p>',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe('HTML', () => {
html = $generateHtmlFromNodes(editor, selection);
});

expect(html).toBe('<span style="white-space: pre-wrap;">World</span>');
expect(html).toBe('World');
});

test(`[Lexical -> HTML]: Default selection (undefined) should serialize entire editor state`, () => {
Expand Down Expand Up @@ -145,7 +145,7 @@ describe('HTML', () => {
});

expect(html).toBe(
'<p><span style="white-space: pre-wrap;">Hello</span></p><p><span style="white-space: pre-wrap;">World</span></p>',
'<p>Hello</p><p>World</p>',
);
});

Expand Down Expand Up @@ -175,7 +175,7 @@ describe('HTML', () => {
});

expect(html).toBe(
'<p style="text-align: center;"><span style="white-space: pre-wrap;">Hello world!</span></p>',
'<p style="text-align: center;">Hello world!</p>',
);
});

Expand Down Expand Up @@ -205,7 +205,7 @@ describe('HTML', () => {
});

expect(html).toBe(
'<p style="text-align: center;"><span style="white-space: pre-wrap;">Hello world!</span></p>',
'<p style="text-align: center;">Hello world!</p>',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ describe('LexicalTableNode tests', () => {
// Make sure paragraph is inserted inside empty cells
const emptyCell = '<td><p><br></p></td>';
expect(testEnv.innerHTML).toBe(
`<table><tr><td><p><span data-lexical-text="true">Hello there</span></p></td><td><p><span data-lexical-text="true">General Kenobi!</span></p></td></tr><tr><td><p><span data-lexical-text="true">Lexical is nice</span></p></td>${emptyCell}</tr></table>`,
`<table><tr><td><p><span style="color: rgb(0, 0, 0);" data-lexical-text="true">Hello there</span></p></td><td><p><span style="color: rgb(0, 0, 0);" data-lexical-text="true">General Kenobi!</span></p></td></tr><tr><td><p><span style="color: rgb(0, 0, 0);" data-lexical-text="true">Lexical is nice</span></p></td>${emptyCell}</tr></table>`,
);
});

Expand Down
Loading

0 comments on commit a62d838

Please sign in to comment.