diff --git a/packages/core/editor/src/index.ts b/packages/core/editor/src/index.ts index 6a247984..4a208b80 100644 --- a/packages/core/editor/src/index.ts +++ b/packages/core/editor/src/index.ts @@ -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'; diff --git a/packages/core/editor/src/parsers/deserializeListNodes.ts b/packages/core/editor/src/parsers/deserializeListNodes.ts new file mode 100644 index 00000000..445f0e30 --- /dev/null +++ b/packages/core/editor/src/parsers/deserializeListNodes.ts @@ -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[]; +} diff --git a/packages/plugins/lists/src/plugin/BulletedList.tsx b/packages/plugins/lists/src/plugin/BulletedList.tsx index 83a02213..ca88ff3e 100644 --- a/packages/plugins/lists/src/plugin/BulletedList.tsx +++ b/packages/plugins/lists/src/plugin/BulletedList.tsx @@ -1,6 +1,6 @@ import { buildBlockData, - deserializeTextNodes, + deserializeListNodes, generateId, serializeTextNodes, serializeTextNodesIntoMarkdown, @@ -36,35 +36,13 @@ const BulletedList = new YooptaPlugin>({ 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; + } } }, }, @@ -78,6 +56,7 @@ const BulletedList = new YooptaPlugin>({ }, markdown: { serialize: (element, text) => { + console.log({element}) return `- ${serializeTextNodesIntoMarkdown(element.children)}`; }, }, diff --git a/packages/plugins/lists/src/plugin/NumberedList.tsx b/packages/plugins/lists/src/plugin/NumberedList.tsx index f8430db6..598366bb 100644 --- a/packages/plugins/lists/src/plugin/NumberedList.tsx +++ b/packages/plugins/lists/src/plugin/NumberedList.tsx @@ -5,7 +5,7 @@ import { deserializeTextNodes, serializeTextNodes, serializeTextNodesIntoMarkdown, - Blocks, + Blocks, deserializeListNodes, } from '@yoopta/editor'; import { NumberedListCommands } from '../commands'; import { NumberedListRender } from '../elements/NumberedList'; @@ -39,35 +39,13 @@ const NumberedList = new YooptaPlugin>({ 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; + } } }, }, diff --git a/packages/plugins/lists/src/plugin/TodoList.tsx b/packages/plugins/lists/src/plugin/TodoList.tsx index 813c4f56..4fd5527c 100644 --- a/packages/plugins/lists/src/plugin/TodoList.tsx +++ b/packages/plugins/lists/src/plugin/TodoList.tsx @@ -1,5 +1,5 @@ import { - buildBlockData, + buildBlockData, deserializeListNodes, deserializeTextNodes, generateId, serializeTextNodes, @@ -39,38 +39,13 @@ const TodoList = new YooptaPlugin>({ 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; + } } }, },