From 5a1a453798d37948e7cd24b239eb3905b83ea7e0 Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Mon, 23 Feb 2026 11:55:40 +0100 Subject: [PATCH] fix formatting loss --- .../src/components/stories/SlateEditor.tsx | 94 ++++++++++++++----- 1 file changed, 73 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/stories/SlateEditor.tsx b/frontend/src/components/stories/SlateEditor.tsx index da1a0da..580d144 100644 --- a/frontend/src/components/stories/SlateEditor.tsx +++ b/frontend/src/components/stories/SlateEditor.tsx @@ -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 = {}) => { + 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,32 +224,54 @@ 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, '&') + .replace(//g, '>'); + if (leaf.bold) text = `${text}`; + if (leaf.italic) text = `${text}`; + if (leaf.underline) text = `${text}`; + if (leaf.strikethrough) text = `${text}`; + 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(`

${text}

`); + case 'heading-one': { + const inner = element.children.map(serializeLeaf).join(''); + htmlParts.push(`

${inner}

`); break; - case 'heading-two': - htmlParts.push(`

${text}

`); + } + case 'heading-two': { + const inner = element.children.map(serializeLeaf).join(''); + htmlParts.push(`

${inner}

`); break; - case 'heading-three': - htmlParts.push(`

${text}

`); + } + case 'heading-three': { + const inner = element.children.map(serializeLeaf).join(''); + htmlParts.push(`

${inner}

`); 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(``); break; + } case 'paragraph': - default: - htmlParts.push(text ? `

${text}

` : '

'); + default: { + const inner = element.children.map(serializeLeaf).join(''); + htmlParts.push(inner ? `

${inner}

` : '

'); break; + } } } });