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

Block-Editor: Enable pasting HTML (from Google Doc and RichText) #20262

Open
wants to merge 4 commits into
base: develop
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { BlocksContent, type BlocksContentProps } from './BlocksContent';
import { BlocksToolbar } from './BlocksToolbar';
import { EditorLayout } from './EditorLayout';
import { type ModifiersStore, modifiers } from './Modifiers';
import { withHtml } from './plugins/withHTML';
import { withImages } from './plugins/withImages';
import { withLinks } from './plugins/withLinks';
import { withStrapiSchema } from './plugins/withStrapiSchema';
Expand Down Expand Up @@ -175,7 +176,14 @@ const BlocksEditor = React.forwardRef<{ focus: () => void }, BlocksEditorProps>(
({ disabled = false, name, onChange, value, error, ...contentProps }, forwardedRef) => {
const { formatMessage } = useIntl();
const [editor] = React.useState(() =>
pipe(withHistory, withImages, withStrapiSchema, withReact, withLinks)(createEditor())
pipe(
withHistory,
withImages,
withStrapiSchema,
withReact,
withLinks,
withHtml
)(createEditor())
);
const [liveText, setLiveText] = React.useState('');
const ariaDescriptionId = React.useId();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { Descendant, Transforms, Node } from 'slate';
import { type Editor } from 'slate';
import { jsx } from 'slate-hyperscript';

type TElementTag =
| 'A'
| 'BLOCKQUOTE'
| 'H1'
| 'H2'
| 'H3'
| 'H4'
| 'H5'
| 'H6'
| 'IMG'
| 'LI'
| 'OL'
| 'P'
| 'PRE'
| 'UL';
type TTextTag = 'CODE' | 'DEL' | 'EM' | 'I' | 'S' | 'STRONG' | 'U';

const ELEMENT_TAGS = {
A: (el: HTMLElement) => ({ type: 'link', url: el.getAttribute('href') }),
BLOCKQUOTE: () => ({ type: 'quote' }),
H1: () => ({ type: 'heading', level: 1 }),
H2: () => ({ type: 'heading', level: 2 }),
H3: () => ({ type: 'heading', level: 3 }),
H4: () => ({ type: 'heading', level: 4 }),
H5: () => ({ type: 'heading', level: 5 }),
H6: () => ({ type: 'heading', level: 6 }),
IMG: (el: HTMLElement) => ({ type: 'image', url: el.getAttribute('src') }),
LI: () => ({ type: 'list-item' }),
UL: () => ({ type: 'list', format: 'unordered' }),
OL: () => ({ type: 'list', format: 'ordered' }),
P: () => ({ type: 'paragraph' }),
PRE: () => ({ type: 'code' }),
};

const TEXT_TAGS = {
CODE: () => ({ code: true }),
DEL: () => ({ strikethrough: true }),
EM: () => ({ italic: true }),
I: () => ({ italic: true }),
S: () => ({ strikethrough: true }),
B: () => ({ bold: true }),
STRONG: () => ({ bold: true }),
U: () => ({ underline: true }),
};

function getSpan(el: HTMLElement) {
const attrs = [];
if (el.style.fontWeight === '700') {
attrs.push({ bold: true });
}
if (el.style.fontStyle === 'italic') {
attrs.push({ italic: true });
}
if (el.style.textDecoration === 'underline') {
attrs.push({ underline: true });
}
return attrs.reduce((acc, attr) => ({ ...acc, ...attr }), {});
}

function checkIfGoogleDoc(el: HTMLElement) {
return el.nodeName === 'B' && el.id?.startsWith('docs-internal-guid-');
}

const deserialize = (
el: ChildNode,
parentNodeName?: string
): string | null | Descendant | (string | null | { text: string } | Descendant | Node)[] => {
if (el.nodeType === 3) {
return el.textContent;
} else if (el.nodeType !== 1) {
return null;
} else if (el.nodeName === 'BR') {
return jsx('element', {}, [{ text: '' }]);
}

const { nodeName } = el;
let parent = el;

if (nodeName === 'PRE' && el.childNodes[0] && el.childNodes[0].nodeName === 'CODE') {
parent = el.childNodes[0];
}
let children = Array.from(parent.childNodes)
.map((childNode) => deserialize(childNode, el.nodeName as TElementTag))
.flat();

if (children.length === 0) {
children = [{ text: '' }];
}

if (nodeName === 'BODY') {
return jsx('fragment', {}, children);
}

// Google Docs adds a <p> tag in a <li> tag, that must be omitted
if (nodeName === 'P' && parentNodeName && ELEMENT_TAGS[parentNodeName as TElementTag]) {
return jsx('fragment', {}, children);
}

// Google Docs wraps the content in a <b> tag with an id starting with 'docs-internal-guid-'
if (checkIfGoogleDoc(el as HTMLElement)) {
return jsx('fragment', {}, children);
}

// Google Docs expresses bold/italic/underlined text with a <span> tag
if (nodeName === 'SPAN') {
const attrs = getSpan(el as HTMLElement);
if (attrs) {
return children.map((child) => jsx('text', attrs, child));
}
}

if (ELEMENT_TAGS[nodeName as TElementTag]) {
const attrs = ELEMENT_TAGS[nodeName as TElementTag](el as HTMLElement);
if (children) {
return jsx('element', attrs, children);
}
}

if (TEXT_TAGS[nodeName as TTextTag]) {
const attrs = TEXT_TAGS[nodeName as TTextTag]();
return children.map((child) => jsx('text', attrs, child));
}

return children;
};

export function withHtml(editor: Editor) {
const { insertData, isVoid } = editor;

editor.isVoid = (element) => {
return element.type === 'image' ? true : isVoid(element);
};

editor.insertData = (data) => {
const html = data.getData('text/html');

if (html) {
const parsed = new DOMParser().parseFromString(html, 'text/html');
const fragment = deserialize(parsed.body);
Transforms.insertFragment(editor, fragment as Node[]);
return;
}

insertData(data);
};

return editor;
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { type BaseEditor, Path, Transforms, Range, Point, Editor } from 'slate';

import { insertLink } from '../utils/links';

interface LinkEditor extends BaseEditor {
lastInsertedLinkPath: Path | null;
shouldSaveLinkPath: boolean;
}

const withLinks = (editor: Editor) => {
const { isInline, apply, insertText, insertData } = editor;
const { isInline, apply, insertText } = editor;

// Links are inline elements, so we need to override the isInline method for slate
editor.isInline = (element) => {
Expand Down Expand Up @@ -73,26 +71,6 @@ const withLinks = (editor: Editor) => {
insertText(text);
};

// Add data as a clickable link if its a valid URL
editor.insertData = (data) => {
const pastedText = data.getData('text/plain');

if (pastedText) {
try {
// eslint-disable-next-line no-new
new URL(pastedText);
// Do not show link popup on copy-paste a link, so do not save its path
editor.shouldSaveLinkPath = false;
insertLink(editor, { url: pastedText });
return;
} catch (error) {
// continue normal data insertion
}
}

insertData(data);
};

return editor;
};

Expand Down
1 change: 1 addition & 0 deletions packages/core/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@
"sift": "16.0.1",
"slate": "0.94.1",
"slate-history": "0.93.0",
"slate-hyperscript": "0.100.0",
"slate-react": "0.98.3",
"style-loader": "3.3.4",
"typescript": "5.2.2",
Expand Down
27 changes: 16 additions & 11 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7920,6 +7920,7 @@ __metadata:
sift: "npm:16.0.1"
slate: "npm:0.94.1"
slate-history: "npm:0.93.0"
slate-hyperscript: "npm:0.100.0"
slate-react: "npm:0.98.3"
style-loader: "npm:3.3.4"
styled-components: "npm:5.3.3"
Expand Down Expand Up @@ -13181,17 +13182,10 @@ __metadata:
languageName: node
linkType: hard

"caniuse-lite@npm:^1.0.30001541":
version: 1.0.30001542
resolution: "caniuse-lite@npm:1.0.30001542"
checksum: 07b14b8341d7bf0ea386a5fa5b5edbee41d81dfc072d3d11db22dd1d7a929358f522b16fdf3cbd154c8a5cae84662578cf5c9e490e7d7606ee7d156ccf07c9fa
languageName: node
linkType: hard

"caniuse-lite@npm:^1.0.30001565":
version: 1.0.30001576
resolution: "caniuse-lite@npm:1.0.30001576"
checksum: 51632942733593f310e581bd91c9558b8d75fbf67160a39f8036d2976cd7df9183e96d4c9d9e6f18e0205950b940d9c761bcfb7810962d7899f8a1179fde6e3f
"caniuse-lite@npm:^1.0.30001541, caniuse-lite@npm:^1.0.30001565":
version: 1.0.30001615
resolution: "caniuse-lite@npm:1.0.30001615"
checksum: 3dfb48e9b8fbf4d550cf204075b170ef4a5bcbc7faf9e14b03dc7456ac1aec2da1405d4fca808bc72b47a2ca0818d6d138aa62769548472440ff605ad1313719
languageName: node
linkType: hard

Expand Down Expand Up @@ -28224,6 +28218,17 @@ __metadata:
languageName: node
linkType: hard

"slate-hyperscript@npm:0.100.0":
version: 0.100.0
resolution: "slate-hyperscript@npm:0.100.0"
dependencies:
is-plain-object: "npm:^5.0.0"
peerDependencies:
slate: ">=0.65.3"
checksum: 4ec39c04675b89db313c102dc19de0351aedd666d5e6ca37b6aa4850ef547fe849cc01aae4db6304c86a1db61aa1b428717fade59e349cf3fdd301bf88648ec6
languageName: node
linkType: hard

"slate-react@npm:0.98.3":
version: 0.98.3
resolution: "slate-react@npm:0.98.3"
Expand Down