Skip to content

[lexical-playground] Bug Fix: processing html in paste command to correct markup #7711

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
244 changes: 243 additions & 1 deletion packages/lexical-playground/src/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import type {JSX} from 'react';

import {$insertDataTransferForRichText} from '@lexical/clipboard';
import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
import {CharacterLimitPlugin} from '@lexical/react/LexicalCharacterLimitPlugin';
import {CheckListPlugin} from '@lexical/react/LexicalCheckListPlugin';
Expand All @@ -27,7 +28,7 @@
import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
import {useLexicalEditable} from '@lexical/react/useLexicalEditable';
import {CAN_USE_DOM} from '@lexical/utils';
import * as React from 'react';
import {$getSelection, COMMAND_PRIORITY_NORMAL, PASTE_COMMAND} from 'lexical';
import {useEffect, useState} from 'react';

import {createWebsocketProvider} from './collaboration';
Expand Down Expand Up @@ -80,6 +81,207 @@
// @ts-expect-error
window.parent != null && window.parent.frames.right === window;

function fixMsListMarkup(html: string) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');

const lists: Record<string, Array<{wrapper: Element; level: number}>> = {};

doc.querySelectorAll('.ListContainerWrapper').forEach((wrapper) => {
const li = wrapper.querySelector('li[data-listid]');
const listId = (li as HTMLElement)?.dataset.listid;

if (listId) {
lists[listId] ??= [];
lists[listId].push({
level: parseInt(li.dataset.ariaLevel, 10),
wrapper,

Check failure on line 98 in packages/lexical-playground/src/Editor.tsx

View workflow job for this annotation

GitHub Actions / core-tests / integrity (20.11.0)

Property 'dataset' does not exist on type 'Element'.

Check failure on line 98 in packages/lexical-playground/src/Editor.tsx

View workflow job for this annotation

GitHub Actions / core-tests / integrity (20.11.0)

'li' is possibly 'null'.
});
}
});

if (Object.keys(lists).length < 1) {
return html;
}

Object.values(lists).forEach((list) => {
const {wrapper: parentWrapper} = list.shift();

Check failure on line 109 in packages/lexical-playground/src/Editor.tsx

View workflow job for this annotation

GitHub Actions / core-tests / integrity (20.11.0)

Property 'wrapper' does not exist on type '{ wrapper: Element; level: number; } | undefined'.
let parent = parentWrapper.querySelector('ol, ul');
parentWrapper.replaceWith(parent);

let currentLevel = 1;
let documentCurrentLevel = 1;
list.forEach(({wrapper, level}) => {
const listElement = wrapper.querySelector('ol, ul');
if (!listElement) {
return;
}

if (level > documentCurrentLevel) {
let target = null;
while (level > documentCurrentLevel) {
documentCurrentLevel += 1;
if (parent.lastElementChild) {
currentLevel += 1;
target = parent.lastElementChild;
}
}

target.append(listElement);
parent = listElement;
} else {
if (level < currentLevel) {
while (level < documentCurrentLevel) {
documentCurrentLevel -= 1;
const candidate = parent.parentNode.closest('ol, ul');
if (candidate) {
currentLevel -= 1;
parent = candidate;
}
}
}
parent.append(...listElement.querySelectorAll('li'));
listElement.remove();
}

wrapper.remove();
});
});

return doc.body.innerHTML;
}

function fixMsParaStylesMarkup(htmlString: string) {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');

const PARA_STYLE_TO_TAG = {
Subtitle: 'h2',
Title: 'h1',
'heading 1': 'h1',
'heading 2': 'h2',
'heading 3': 'h3',
'heading 4': 'h4',
'heading 5': 'h5',
'heading 6': 'h6',
};
const PARA_STYLE_DATA_IDENTIFIER = 'data-ccp-parastyle';
const DATA_IDENTIFIERS_TO_SKIP = ['data-ccp-props'];

const elementsWithParaStyle = doc.querySelectorAll(
`[${PARA_STYLE_DATA_IDENTIFIER}]`,
);

elementsWithParaStyle.forEach((element) => {
const paraStyle = element.getAttribute(PARA_STYLE_DATA_IDENTIFIER);

if (
paraStyle &&
!DATA_IDENTIFIERS_TO_SKIP.some((identifier) =>
element.getAttribute(identifier),
)
) {
// Normalize the style name (trim whitespace and convert to lowercase)
const normalizedStyle = paraStyle.trim().toLowerCase();

// Check if we have a mapping for this style
const targetTag = PARA_STYLE_TO_TAG[normalizedStyle];

Check failure on line 190 in packages/lexical-playground/src/Editor.tsx

View workflow job for this annotation

GitHub Actions / core-tests / integrity (20.11.0)

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ Subtitle: string; Title: string; 'heading 1': string; 'heading 2': string; 'heading 3': string; 'heading 4': string; 'heading 5': string; 'heading 6': string; }'.
if (targetTag && element.tagName.toLowerCase() !== targetTag) {
// Create the new element with the target tag
const newElement = doc.createElement(targetTag);

// Copy all attributes except the paragraph style identifier
Array.from(element.attributes).forEach((attr) => {
if (attr.name !== PARA_STYLE_DATA_IDENTIFIER) {
newElement.setAttribute(attr.name, attr.value);
}
});

// Copy all child nodes
while (element.firstChild) {
newElement.appendChild(element.firstChild);
}

// Replace the original element with the new one
element.parentNode?.replaceChild(newElement, element);
}
}
});

return doc.body.innerHTML;
}

function fixMSOfficeStyles(htmlString: string) {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');

function calculateInheritedStyles(
element: HTMLElement,
parentStyles: Record<string, string> = {},
) {
const computedStyles = window.getComputedStyle(element);
const inheritedStyles: Record<string, string> = {...parentStyles};

const formatProps = [
'fontWeight',
'fontStyle',
'fontSize',
'color',
'textDecoration',
'textTransform',
'background',
'margin',
'padding',
];

formatProps.forEach((prop: string) => {
const computedValue = computedStyles.getPropertyValue(prop);
const inlineValue =
element.style.getPropertyValue(prop) || element.style[prop];
const value = inlineValue || computedValue;

Check failure on line 243 in packages/lexical-playground/src/Editor.tsx

View workflow job for this annotation

GitHub Actions / core-tests / integrity (20.11.0)

Element implicitly has an 'any' type because index expression is not of type 'number'.

if (
value &&
![
'normal',
'initial',
'inherit',
'unset',
'auto',
'none',
'transparent',
].includes(value.trim())
) {
inheritedStyles[prop] = value;
}
});

Object.keys(inheritedStyles).forEach((prop) => {
element.style[prop] = inheritedStyles[prop];
});

Check failure on line 263 in packages/lexical-playground/src/Editor.tsx

View workflow job for this annotation

GitHub Actions / core-tests / integrity (20.11.0)

Element implicitly has an 'any' type because index expression is not of type 'number'.

Array.from(element.children).forEach((child) => {
calculateInheritedStyles(child as HTMLElement, inheritedStyles);
});
}

const allSpans = doc.querySelectorAll('span:not(span span)');
allSpans.forEach((span) => {
calculateInheritedStyles(span as HTMLElement);
});

return doc.body.innerHTML;
}

function processHtmlPipeline(
html: string,
steps: Array<(input: string) => string>,
): string {
return steps.reduce((currentHtml, step) => step(currentHtml), html);
}

export default function Editor(): JSX.Element {
const {historyState} = useSharedHistoryContext();
const {
Expand Down Expand Up @@ -123,6 +325,46 @@
}
};

useEffect(() => {
return editor.registerCommand(
PASTE_COMMAND,
(event: ClipboardEvent) => {
const clipboard = event.clipboardData;
if (!clipboard) {
return false;
}

const pastedHtml = clipboard.getData('text/html');

const fixSteps = [
fixMSOfficeStyles,
fixMsListMarkup,
fixMsParaStylesMarkup,
];

const processedHtml = processHtmlPipeline(pastedHtml, fixSteps);

const modifiedDataTransfer = new DataTransfer();
modifiedDataTransfer.setData('text/html', processedHtml);

const modifiedClipboardEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: modifiedDataTransfer,
});

$insertDataTransferForRichText(
modifiedClipboardEvent.clipboardData,
$getSelection(),
editor,
);

Check failure on line 360 in packages/lexical-playground/src/Editor.tsx

View workflow job for this annotation

GitHub Actions / core-tests / integrity (20.11.0)

Argument of type 'DataTransfer | null' is not assignable to parameter of type 'DataTransfer'.

return true;
},
COMMAND_PRIORITY_NORMAL,
);
}, [editor]);

useEffect(() => {
const updateViewPortWidth = () => {
const isNextSmallWidthViewport =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,23 @@ import {
} from './utils';

export function parseAllowedFontSize(input: string): string {
const match = input.match(/^(\d+(?:\.\d+)?)px$/);
if (match) {
const n = Number(match[1]);
if (n >= MIN_ALLOWED_FONT_SIZE && n <= MAX_ALLOWED_FONT_SIZE) {
return input;
}
const pxMatch = input.match(/^(\d+(?:\.\d+)?)px$/);
const ptMatch = input.match(/^(\d+(?:\.\d+)?)pt$/);

let pxValue: number;

if (pxMatch) {
pxValue = Number(pxMatch[1]);
} else if (ptMatch) {
pxValue = Number(ptMatch[1]) * (4 / 3);
} else {
return '';
}

if (pxValue >= MIN_ALLOWED_FONT_SIZE && pxValue <= MAX_ALLOWED_FONT_SIZE) {
return input;
}

return '';
}

Expand Down
Loading