Skip to content

Commit 1f03df5

Browse files
authored
Merge pull request udecode#4582 from udecode/fix/list-paste
Fix copying list from notion
2 parents e2f5977 + fa0779c commit 1f03df5

4 files changed

Lines changed: 201 additions & 11 deletions

File tree

.changeset/strange-eggs-taste.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@platejs/list': patch
3+
---
4+
5+
Fix copying list from notion

packages/list/src/lib/BaseListPlugin.tsx

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,23 +51,107 @@ export const BaseListPlugin = createTSlatePlugin<BaseListConfig>({
5151
const document = new DOMParser().parseFromString(data, 'text/html');
5252
const { body } = document;
5353

54+
// First pass: flatten nested UL/OL that are inside LI elements
55+
// We need to move them to be siblings of their parent LI
56+
const lisWithNestedLists: {
57+
li: Element;
58+
nestedLists: Element[];
59+
}[] = [];
60+
5461
traverseHtmlElements(body, (element) => {
5562
if (element.tagName === 'LI') {
63+
const nestedLists: Element[] = [];
64+
// Find nested UL/OL elements
65+
Array.from(element.children).forEach((child) => {
66+
if (child.tagName === 'UL' || child.tagName === 'OL') {
67+
nestedLists.push(child);
68+
}
69+
});
70+
71+
if (nestedLists.length > 0) {
72+
lisWithNestedLists.push({ li: element, nestedLists });
73+
}
74+
}
75+
return true;
76+
});
77+
78+
// Move nested lists to be after their parent LI
79+
lisWithNestedLists.forEach(({ li, nestedLists }) => {
80+
nestedLists.forEach((nestedList) => {
81+
// Remove the nested list from inside the LI
82+
nestedList.remove();
83+
// Insert it after the LI in the parent container
84+
if (li.parentNode) {
85+
li.parentNode.insertBefore(nestedList, li.nextSibling);
86+
}
87+
});
88+
});
89+
90+
// Second pass: process LI elements (now without nested lists inside them)
91+
traverseHtmlElements(body, (element) => {
92+
if (element.tagName === 'LI') {
93+
const htmlElement = element as HTMLElement;
5694
const { childNodes } = element;
5795

58-
// replace li block children (e.g. p) by their children
96+
// Process li children and flatten block elements
5997
const liChildren: Node[] = [];
98+
6099
childNodes.forEach((child) => {
61-
if (isHtmlBlockElement(child as Element)) {
62-
liChildren.push(...child.childNodes);
63-
} else {
64-
liChildren.push(child);
100+
if (child.nodeType === Node.ELEMENT_NODE) {
101+
const childElement = child as Element;
102+
if (isHtmlBlockElement(childElement)) {
103+
// Replace block elements (e.g. p) with their children
104+
liChildren.push(...childElement.childNodes);
105+
return;
106+
}
65107
}
108+
liChildren.push(child);
66109
});
67110

68111
element.replaceChildren(...liChildren);
69112

70-
// TODO: recursive check on ul parents for indent
113+
// Check for aria-level first (Google Docs uses this)
114+
const ariaLevel = element.getAttribute('aria-level');
115+
116+
if (ariaLevel) {
117+
// aria-level takes precedence
118+
htmlElement.dataset.indent = ariaLevel;
119+
} else {
120+
// Calculate indent level based on nested UL/OL parents
121+
let indent = 0;
122+
let parent = element.parentElement;
123+
while (parent && parent !== body) {
124+
if (parent.tagName === 'UL' || parent.tagName === 'OL') {
125+
indent++;
126+
}
127+
parent = parent.parentElement;
128+
}
129+
130+
// Set indent level as data attribute
131+
if (indent > 0) {
132+
htmlElement.dataset.indent = String(indent);
133+
}
134+
}
135+
136+
// Set list style type from inline style or parent list type
137+
const listStyleType = htmlElement.style.listStyleType;
138+
if (listStyleType) {
139+
htmlElement.dataset.listStyleType = listStyleType;
140+
} else {
141+
// Fallback to parent list type
142+
const listParent = element.closest('ul, ol');
143+
if (listParent) {
144+
const parentListStyleType = (listParent as HTMLElement)
145+
.style.listStyleType;
146+
if (parentListStyleType) {
147+
htmlElement.dataset.listStyleType = parentListStyleType;
148+
} else if (listParent.tagName === 'UL') {
149+
htmlElement.dataset.listStyleType = 'disc';
150+
} else if (listParent.tagName === 'OL') {
151+
htmlElement.dataset.listStyleType = 'decimal';
152+
}
153+
}
154+
}
71155

72156
return false;
73157
}
@@ -95,10 +179,19 @@ export const BaseListPlugin = createTSlatePlugin<BaseListConfig>({
95179
},
96180
],
97181
parse: ({ editor, element, getOptions }) => {
182+
// Get indent from data-indent or aria-level (gdoc)
183+
const dataIndent = element.dataset.indent;
184+
const ariaLevel = element.getAttribute('aria-level');
185+
const indent = dataIndent ? Number(dataIndent) : Number(ariaLevel);
186+
187+
// Get list style type from data attribute or use default
188+
const dataListStyleType = element.dataset.listStyleType;
189+
const listStyleType =
190+
dataListStyleType || getOptions().getListStyleType?.(element);
191+
98192
return {
99-
// gdoc uses aria-level attribute
100-
indent: Number(element.getAttribute('aria-level')),
101-
listStyleType: getOptions().getListStyleType?.(element),
193+
indent: indent || undefined,
194+
listStyleType: listStyleType || undefined,
102195
type: editor.getType(KEYS.p),
103196
};
104197
},

packages/list/src/lib/ListPlugin.spec.tsx

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const createClipboardData = (html: string, rtf?: string): DataTransfer =>
3030
}) as any;
3131

3232
describe('when insertData disc and decimal from gdocs', () => {
33-
it('should ', () => {
33+
it('should handle Google Docs nested lists', () => {
3434
const e = (
3535
<editor>
3636
<hp>
@@ -118,3 +118,95 @@ describe('when insertData disc and decimal from gdocs', () => {
118118
]);
119119
});
120120
});
121+
122+
describe('when insertData with nested ul inside li', () => {
123+
it('should handle li with nested ul correctly', () => {
124+
const e = (
125+
<editor>
126+
<hp>
127+
<cursor />
128+
</hp>
129+
</editor>
130+
) as any;
131+
const editor = createPlateEditor({
132+
plugins: [
133+
ImagePlugin,
134+
HorizontalRulePlugin,
135+
LinkPlugin,
136+
TablePlugin,
137+
BasicBlocksPlugin,
138+
BasicMarksPlugin,
139+
TablePlugin,
140+
LineHeightPlugin.extend(injectConfig),
141+
TextAlignPlugin.extend(injectConfig),
142+
IndentPlugin.extend(injectConfig),
143+
BaseListPlugin,
144+
DocxPlugin,
145+
JuicePlugin,
146+
],
147+
selection: e.selection,
148+
value: e.children,
149+
});
150+
151+
editor.tf.insertData(
152+
createClipboardData(
153+
`<ul>
154+
<li>Item 1
155+
<ul>
156+
<li>Item 1.1
157+
<ul>
158+
<li>Item 1.1.1</li>
159+
</ul>
160+
</li>
161+
</ul>
162+
</li>
163+
<li>Item 2</li>
164+
</ul>`
165+
)
166+
);
167+
168+
expect(editor.children).toEqual([
169+
{
170+
children: [
171+
{
172+
text: 'Item 1 ', // Note: trailing space from HTML
173+
},
174+
],
175+
indent: 1,
176+
listStyleType: 'disc',
177+
type: 'p',
178+
},
179+
{
180+
children: [
181+
{
182+
text: 'Item 1.1 ', // Note: trailing space from HTML
183+
},
184+
],
185+
indent: 2,
186+
listStyleType: 'disc',
187+
type: 'p',
188+
},
189+
{
190+
children: [
191+
{
192+
text: 'Item 1.1.1',
193+
},
194+
],
195+
indent: 3,
196+
listStyleType: 'disc',
197+
type: 'p',
198+
},
199+
{
200+
children: [
201+
{
202+
text: 'Item 2',
203+
},
204+
],
205+
indent: 1,
206+
listStart: 2, // Second item in the list
207+
listStyleType: 'disc',
208+
type: 'p',
209+
},
210+
]);
211+
});
212+
});

packages/markdown/src/lib/deserializer/utils/markdownToSlateNodesSafely.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import {
44
type DeserializeMdOptions,
55
markdownToSlateNodes,
66
} from '../deserializeMd';
7-
import { splitIncompleteMdx } from './splitIncompleteMdx';
87
import { deserializeInlineMd } from './deserializeInlineMd';
8+
import { splitIncompleteMdx } from './splitIncompleteMdx';
99

1010
export const markdownToSlateNodesSafely = (
1111
editor: SlateEditor,

0 commit comments

Comments
 (0)