From 857871273d17a012b12ce7ca23ed4e458cb5c825 Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Mon, 22 Sep 2025 15:43:25 +0200 Subject: [PATCH] fix pre formatting --- .../components/stories/PortableTextEditor.tsx | 75 ++++++++++++++++--- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/stories/PortableTextEditor.tsx b/frontend/src/components/stories/PortableTextEditor.tsx index fd39394..c8b693b 100644 --- a/frontend/src/components/stories/PortableTextEditor.tsx +++ b/frontend/src/components/stories/PortableTextEditor.tsx @@ -44,7 +44,8 @@ function htmlToPortableTextBlocks(html: string): PortableTextBlock[] { const paragraphs = doc.querySelectorAll('p, h1, h2, h3, h4, h5, h6, blockquote, div'); if (paragraphs.length === 0) { - // Fallback: treat as single paragraph + // Fallback: treat body text as single block, preserving newlines + const bodyText = doc.body.textContent || ''; return [{ _type: 'block', _key: generateKey(), @@ -53,12 +54,35 @@ function htmlToPortableTextBlocks(html: string): PortableTextBlock[] { children: [{ _type: 'span', _key: generateKey(), - text: doc.body.textContent || '', + text: bodyText, // Keep newlines in the text marks: [] }] }]; } + // Check if we have only one paragraph that might contain newlines + if (paragraphs.length === 1) { + const singleParagraph = paragraphs[0]; + const textContent = singleParagraph.textContent || ''; + + // If this single paragraph contains newlines, preserve them in a single block + if (textContent.includes('\n')) { + const style = getStyleFromElement(singleParagraph); + return [{ + _type: 'block', + _key: generateKey(), + style, + markDefs: [], + children: [{ + _type: 'span', + _key: generateKey(), + text: textContent, // Keep newlines in the text + marks: [] + }] + }]; + } + } + // Process all elements in document order to maintain sequence const allElements = Array.from(doc.body.querySelectorAll('*')); const processedElements = new Set(); @@ -88,7 +112,10 @@ function htmlToPortableTextBlocks(html: string): PortableTextBlock[] { (element.tagName === 'PRE' && element.querySelector('code'))) { const codeEl = element.tagName === 'CODE' ? element : element.querySelector('code'); if (codeEl) { - const code = codeEl.textContent || ''; + // Use innerText to preserve newlines and whitespace formatting + // innerText respects CSS white-space property, so
 formatting is preserved
+        let code = (codeEl as HTMLElement).innerText || codeEl.textContent || '';
+
         const language = codeEl.getAttribute('class')?.replace('language-', '') || '';
 
         if (code.trim()) {
@@ -107,16 +134,33 @@ function htmlToPortableTextBlocks(html: string): PortableTextBlock[] {
 
     // Handle text blocks (paragraphs, headings, etc.)
     if (['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'DIV'].includes(element.tagName)) {
-      // Skip if this contains already processed elements
-      if (element.querySelector('img') || (element.querySelector('code') && element.querySelector('pre'))) {
+      // Skip if this contains already processed elements (but allow inline code)
+      if (element.querySelector('img')) {
         processedElements.add(element);
         continue;
       }
 
+      // Check if this is a standalone pre/code block that should be handled specially
+      const hasStandaloneCodeBlock = element.querySelector('pre') && element.children.length === 1 && element.children[0].tagName === 'PRE';
+      if (hasStandaloneCodeBlock) {
+        processedElements.add(element);
+        continue; // Let the pre/code block handler take care of this
+      }
+
       const style = getStyleFromElement(element);
-      const text = element.textContent || '';
+
+      // For text content that may contain inline code, preserve formatting better
+      let text: string;
+      if (element.querySelector('code') && !element.querySelector('pre')) {
+        // Has inline code - use innerText to preserve some formatting
+        text = (element as HTMLElement).innerText || element.textContent || '';
+      } else {
+        // Regular text - use textContent, but also check if this might have come from a pre block
+        text = (element as HTMLElement).innerText || element.textContent || '';
+      }
 
       if (text.trim()) {
+        // Keep newlines within a single block - they'll be converted to 
tags later blocks.push({ _type: 'block', _key: generateKey(), @@ -125,7 +169,7 @@ function htmlToPortableTextBlocks(html: string): PortableTextBlock[] { children: [{ _type: 'span', _key: generateKey(), - text, + text, // Keep newlines in the text marks: [] }] }); @@ -161,9 +205,10 @@ function portableTextToHtml(blocks: PortableTextBlock[]): string { .map(child => child._type === 'span' ? child.text || '' : '') .join('') || ''; - if (text.trim() || block.style !== 'normal') { - htmlParts.push(`<${tag}>${text}`); - } + // Convert any remaining newlines in text to
tags for proper display + const textWithBreaks = text.replace(/\n/g, '
'); + // Always include blocks, even empty ones (they represent line breaks/paragraph spacing) + htmlParts.push(`<${tag}>${textWithBreaks}`); } else if (block._type === 'image' && isImageBlock(block)) { // Convert image blocks back to HTML const attrs: string[] = []; @@ -177,7 +222,14 @@ function portableTextToHtml(blocks: PortableTextBlock[]): string { } else if (block._type === 'codeBlock' && isCodeBlock(block)) { // Convert code blocks back to HTML const langClass = block.language ? ` class="language-${block.language}"` : ''; - htmlParts.push(`
${block.code || ''}
`); + // Escape HTML entities in code content to prevent XSS and preserve formatting + const escapedCode = (block.code || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + htmlParts.push(`
${escapedCode}
`); } }); @@ -246,6 +298,7 @@ function generateKey(): string { return Math.random().toString(36).substring(2, 11); } + // Toolbar component function EditorToolbar({ isScrollable,