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

fix: depth issues in BulletedList and NumberedList #418

Merged
Merged
1 change: 1 addition & 0 deletions packages/core/editor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export { getRootBlockElementType, getRootBlockElement } from './utils/blockEleme
export { findPluginBlockByPath } from './utils/findPluginBlockByPath';
export { findSlateBySelectionPath } from './utils/findSlateBySelectionPath';
export { deserializeTextNodes } from './parsers/deserializeTextNodes';
export { deserializeListNodes } from './parsers/deserializeListNodes';
export { serializeTextNodes, serializeTextNodesIntoMarkdown } from './parsers/serializeTextNodes';

export { createYooptaEditor } from './editor';
Expand Down
138 changes: 138 additions & 0 deletions packages/core/editor/src/parsers/deserializeListNodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import {generateId} from '../utils/generateId';
import {YooEditor, YooptaBlockData} from '../editor/types';
import {deserializeTextNodes} from './deserializeTextNodes';
import {Descendant} from 'slate';

type ListHTMLElement = HTMLUListElement | HTMLOListElement | Element;

type DeserializeListBlockOptions = {
depth?: number;
type: 'TodoList' | 'NumberedList' | 'BulletedList';
align: YooptaBlockData['meta']['align'];
};

// recursive function to parse nested lists and return list of Blocks
export function deserializeListNodes(
editor: YooEditor,
listEl: ListHTMLElement,
options: DeserializeListBlockOptions,
): YooptaBlockData[] {
const deserializedListBlocks: YooptaBlockData[] = [];
const depth = typeof options.depth === 'number' ? options.depth : 0;

if (listEl.nodeName === 'UL' || listEl.nodeName === 'OL') {
const listItems = Array.from(listEl.children).filter((el) => el.nodeName === 'LI');

// check if the list is a TodoList or not
const isTodoList = listItems.some((listItem) => {
const hasCheckbox = listItem.querySelector('input[type="checkbox"]');
const textContent = listItem.textContent || '';
const hasBrackets = /\[\s*[xX\s]?\s*\]/.test(textContent);
return hasCheckbox || hasBrackets;
});

if (isTodoList && options.type !== 'TodoList') {
return deserializedListBlocks;
}

if (!isTodoList && options.type === 'TodoList') {
return deserializedListBlocks;
}

listItems.forEach((listItem) => {
const textContent = listItem.textContent || '';
let blockData: YooptaBlockData | null = null;

if (options.type === 'TodoList') {
// check if the list item has a checkbox input or brackets `[]` in the text content
const checkbox = listItem.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
const checked = checkbox ? checkbox.checked : /\[\s*[xX]\s*\]/.test(textContent);

const cleanText = textContent
.replace(/\[\s*[xX\s]?\s*\]/, '')
.replace(/\u00a0/g, ' ')
.trim();

blockData = {
id: generateId(),
type: 'TodoList',
value: [
{
id: generateId(),
type: 'todo-list',
children: deserializeTextNodes(editor, listItem.childNodes),
props: { nodeType: 'block', checked },
},
],
meta: { order: 0, depth, align: options.align },
};
} else {
const listType = options.type === 'NumberedList' ? 'numbered-list' : 'bulleted-list';
blockData = {
id: generateId(),
type: options.type,
value: [
{
id: generateId(),
type: listType,
children: deserializeTextNodes(editor, listItem.childNodes),
props: { nodeType: 'block' },
},
],
meta: { order: 0, depth, align: options.align },
};
}

if (blockData) {
const cleanedData = {
...blockData,
value: sanitizeTextNodes(blockData.value),
}

deserializedListBlocks.push(cleanedData);
}

const nestedLists = Array.from(listItem.children).filter((el) => (el.nodeName === 'UL' || el.nodeName === 'OL'));

// if the list item has nested lists, then parse them recursively by increasing the depth
if (nestedLists.length > 0) {
nestedLists.forEach((nestedList) => {
const nestedBlocks = deserializeListNodes(editor, nestedList as ListHTMLElement, {
...options,
depth: depth + 1,
});
deserializedListBlocks.push(...nestedBlocks);
});
}
});
}

return deserializedListBlocks;
}

function sanitizeTextNodes(descendants: Descendant[]): Descendant[] {
return descendants
.map((descendant) => {
if ('children' in descendant) {
// Recursively clean nested children
return {
...descendant,
children: sanitizeTextNodes(descendant.children),
};
}

// Only include text nodes that has content
if ('text' in descendant) {
// Clean text content for todo lists or empty text nodes
const cleanText = descendant.text
.replace(/\[\s*[xX\s]?\s*\]/, '') // Remove [] for todo lists
.replace(/\u00a0/g, ' ') // Replace non-breaking spaces
.trim();

return cleanText ? { ...descendant, text: cleanText } : null; // Remove if empty
}

return descendant;
})
.filter(Boolean) as Descendant[];
}
33 changes: 6 additions & 27 deletions packages/plugins/lists/src/plugin/BulletedList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
buildBlockData,
deserializeTextNodes,
deserializeListNodes,
generateId,
serializeTextNodes,
serializeTextNodesIntoMarkdown,
Expand Down Expand Up @@ -36,35 +36,13 @@ const BulletedList = new YooptaPlugin<Pick<ListElementMap, 'bulleted-list'>>({
nodeNames: ['UL'],
parse(el, editor) {
if (el.nodeName === 'UL') {
const listItems = el.querySelectorAll('li');

const align = (el.getAttribute('data-meta-align') || 'left') as YooptaBlockData['meta']['align'];
const depth = parseInt(el.getAttribute('data-meta-depth') || '0', 10);

const bulletListBlocks: YooptaBlockData[] = Array.from(listItems)
.filter((listItem) => {
const textContent = listItem.textContent || '';
const isTodoListItem = /\[\s*\S?\s*\]/.test(textContent);

return !isTodoListItem;
})
.map((listItem) => {
return buildBlockData({
id: generateId(),
type: 'BulletedList',
value: [
{
id: generateId(),
type: 'bulleted-list',
children: deserializeTextNodes(editor, listItem.childNodes),
props: { nodeType: 'block' },
},
],
meta: { order: 0, depth: depth, align },
});
});

if (bulletListBlocks.length > 0) return bulletListBlocks;
const deserializedList = deserializeListNodes(editor, el, { type: 'BulletedList', depth, align });
if (deserializedList.length > 0) {
return deserializedList;
}
}
},
},
Expand All @@ -78,6 +56,7 @@ const BulletedList = new YooptaPlugin<Pick<ListElementMap, 'bulleted-list'>>({
},
markdown: {
serialize: (element, text) => {
console.log({element})
return `- ${serializeTextNodesIntoMarkdown(element.children)}`;
},
},
Expand Down
32 changes: 5 additions & 27 deletions packages/plugins/lists/src/plugin/NumberedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
deserializeTextNodes,
serializeTextNodes,
serializeTextNodesIntoMarkdown,
Blocks,
Blocks, deserializeListNodes,
} from '@yoopta/editor';
import { NumberedListCommands } from '../commands';
import { NumberedListRender } from '../elements/NumberedList';
Expand Down Expand Up @@ -39,35 +39,13 @@ const NumberedList = new YooptaPlugin<Pick<ListElementMap, 'numbered-list'>>({
nodeNames: ['OL'],
parse(el, editor) {
if (el.nodeName === 'OL') {
const listItems = el.querySelectorAll('li');

const align = (el.getAttribute('data-meta-align') || 'left') as YooptaBlockData['meta']['align'];
const depth = parseInt(el.getAttribute('data-meta-depth') || '0', 10);

const numberedListBlocks: YooptaBlockData[] = Array.from(listItems)
.filter((listItem) => {
const textContent = listItem.textContent || '';
const isTodoListItem = /\[\s*\S?\s*\]/.test(textContent);

return !isTodoListItem;
})
.map((listItem, i) => {
return Blocks.buildBlockData({
id: generateId(),
type: 'NumberedList',
value: [
{
id: generateId(),
type: 'numbered-list',
children: deserializeTextNodes(editor, listItem.childNodes),
props: { nodeType: 'block' },
},
],
meta: { order: 0, depth, align },
});
});

if (numberedListBlocks.length > 0) return numberedListBlocks;
const deserializedList = deserializeListNodes(editor, el, { type: 'NumberedList', depth, align });
if (deserializedList.length > 0) {
return deserializedList;
}
}
},
},
Expand Down
35 changes: 5 additions & 30 deletions packages/plugins/lists/src/plugin/TodoList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
buildBlockData,
buildBlockData, deserializeListNodes,
deserializeTextNodes,
generateId,
serializeTextNodes,
Expand Down Expand Up @@ -39,38 +39,13 @@ const TodoList = new YooptaPlugin<Pick<ListElementMap, 'todo-list'>>({
nodeNames: ['OL', 'UL'],
parse(el, editor) {
if (el.nodeName === 'OL' || el.nodeName === 'UL') {
const listItems = el.querySelectorAll('li');

const align = (el.getAttribute('data-meta-align') || 'left') as YooptaBlockData['meta']['align'];
const depth = parseInt(el.getAttribute('data-meta-depth') || '0', 10);

const todoListBlocks: YooptaBlockData[] = Array.from(listItems)
.filter((listItem) => {
const textContent = listItem.textContent || '';
const isTodoListItem = /\[\s*\S?\s*\]/.test(textContent);

return isTodoListItem;
})
.map((listItem) => {
const textContent = listItem.textContent || '';
const checked = /\[\s*x\s*\]/i.test(textContent);

return buildBlockData({
id: generateId(),
type: 'TodoList',
value: [
{
id: generateId(),
type: 'todo-list',
children: deserializeTextNodes(editor, listItem.childNodes),
props: { nodeType: 'block', checked: checked },
},
],
meta: { order: 0, depth, align },
});
});

if (todoListBlocks.length > 0) return todoListBlocks;
const deserializedList = deserializeListNodes(editor, el, { type: 'TodoList', depth, align });
if (deserializedList.length > 0) {
return deserializedList;
}
}
},
},
Expand Down