fix formatting loss

This commit is contained in:
Stefan Hardegger
2026-02-23 11:55:40 +01:00
parent 5b1c11ff47
commit 5a1a453798

View File

@@ -70,6 +70,33 @@ const htmlToSlate = (html: string): Descendant[] => {
const nodes: Descendant[] = [];
// Parse inline-formatted children of a block element into CustomText leaves,
// preserving bold/italic/underline/strikethrough marks.
const parseInlineChildren = (element: Element): CustomText[] => {
const children: CustomText[] = [];
const processNode = (node: Node, marks: Partial<CustomText> = {}) => {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
if (text) {
children.push({ text, ...marks });
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as Element;
const newMarks = { ...marks };
const tag = el.tagName.toLowerCase();
if (tag === 'strong' || tag === 'b') newMarks.bold = true;
if (tag === 'em' || tag === 'i') newMarks.italic = true;
if (tag === 'u') newMarks.underline = true;
if (tag === 's' || tag === 'del' || tag === 'strike') newMarks.strikethrough = true;
el.childNodes.forEach(child => processNode(child, newMarks));
}
};
element.childNodes.forEach(child => processNode(child));
return children.length > 0 ? children : [{ text: '' }];
};
// Process all nodes in document order to maintain sequence
const processChildNodes = (parentNode: Node): Descendant[] => {
const results: Descendant[] = [];
@@ -82,19 +109,19 @@ const htmlToSlate = (html: string): Descendant[] => {
case 'h1':
results.push({
type: 'heading-one',
children: [{ text: element.textContent || '' }]
children: parseInlineChildren(element)
});
break;
case 'h2':
results.push({
type: 'heading-two',
children: [{ text: element.textContent || '' }]
children: parseInlineChildren(element)
});
break;
case 'h3':
results.push({
type: 'heading-three',
children: [{ text: element.textContent || '' }]
children: parseInlineChildren(element)
});
break;
case 'blockquote':
@@ -122,23 +149,26 @@ const htmlToSlate = (html: string): Descendant[] => {
});
break;
}
case 'p':
case 'div': {
case 'p': {
// Check if this paragraph contains mixed content (text + images)
if (element.querySelector('img')) {
// Process mixed content - handle both text and images in order
results.push(...processChildNodes(element));
} else {
const text = element.textContent || '';
if (text.trim()) {
results.push({
type: 'paragraph',
children: [{ text }]
});
const inlineChildren = parseInlineChildren(element);
if (inlineChildren.some(c => c.text.trim())) {
results.push({ type: 'paragraph', children: inlineChildren });
}
}
break;
}
case 'div': {
// Always recurse into divs: they may wrap headings or other block elements.
// Using textContent here would flatten everything into a single paragraph
// and silently drop any headings nested inside.
results.push(...processChildNodes(element));
break;
}
case 'br':
// Handle line breaks by creating empty paragraphs
results.push({
@@ -194,34 +224,56 @@ const htmlToSlate = (html: string): Descendant[] => {
const slateToHtml = (nodes: Descendant[]): string => {
const htmlParts: string[] = [];
// Serialize a single leaf with its inline marks applied as HTML tags.
// Text content is escaped so literal <, >, & don't break the markup.
const serializeLeaf = (leaf: CustomText): string => {
let text = leaf.text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
if (leaf.bold) text = `<strong>${text}</strong>`;
if (leaf.italic) text = `<em>${text}</em>`;
if (leaf.underline) text = `<u>${text}</u>`;
if (leaf.strikethrough) text = `<s>${text}</s>`;
return text;
};
nodes.forEach(node => {
if (SlateElement.isElement(node)) {
const element = node as CustomElement;
const text = SlateNode.string(node);
switch (element.type) {
case 'heading-one':
htmlParts.push(`<h1>${text}</h1>`);
case 'heading-one': {
const inner = element.children.map(serializeLeaf).join('');
htmlParts.push(`<h1>${inner}</h1>`);
break;
case 'heading-two':
htmlParts.push(`<h2>${text}</h2>`);
}
case 'heading-two': {
const inner = element.children.map(serializeLeaf).join('');
htmlParts.push(`<h2>${inner}</h2>`);
break;
case 'heading-three':
htmlParts.push(`<h3>${text}</h3>`);
}
case 'heading-three': {
const inner = element.children.map(serializeLeaf).join('');
htmlParts.push(`<h3>${inner}</h3>`);
break;
case 'image':
}
case 'image': {
const attrs: string[] = [];
if (element.src) attrs.push(`src="${element.src}"`);
if (element.alt) attrs.push(`alt="${element.alt}"`);
if (element.caption) attrs.push(`title="${element.caption}"`);
htmlParts.push(`<img ${attrs.join(' ')} />`);
break;
}
case 'paragraph':
default:
htmlParts.push(text ? `<p>${text}</p>` : '<p></p>');
default: {
const inner = element.children.map(serializeLeaf).join('');
htmlParts.push(inner ? `<p>${inner}</p>` : '<p></p>');
break;
}
}
}
});
const html = htmlParts.join('\n');