From 65ee42530fb5f8ad1b606891c3e7447308552469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Lun=C4=97nas?= <34475426+lunenas@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:26:43 +0200 Subject: [PATCH] fix: render proper HTML tags for CMS-bound rich text on canvas Closes #69 CMS rich-text field content rendered all elements as on the canvas because renderNestedRichTextContent always forced spans. Now the useSpanForParagraphs flag propagates correctly: rich text layers (div) get proper block tags, while restrictive tags (p, h1-h6) keep spans to avoid invalid nesting. Also fix layers tree collapsibility for CMS-bound rich text layers. Made-with: Cursor --- app/ycode/components/LayersTree.tsx | 6 +++--- lib/layer-utils.ts | 15 +++++++++++++++ lib/text-format-utils.ts | 18 +++++++++++------- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/app/ycode/components/LayersTree.tsx b/app/ycode/components/LayersTree.tsx index 597deebd..d11806bb 100644 --- a/app/ycode/components/LayersTree.tsx +++ b/app/ycode/components/LayersTree.tsx @@ -35,7 +35,7 @@ import { useAuthStore } from '@/stores/useAuthStore'; // 6. Utils/lib import { cn } from '@/lib/utils'; import { flattenTree, type FlattenedItem } from '@/lib/tree-utilities'; -import { canHaveChildren, getLayerIcon, getLayerName, getCollectionVariable, isTextContentLayer, isRichTextLayer, getRichTextSublayers, getTextStyleSublayers, canMoveLayer, updateLayerProps, filterDisabledSliderLayers, getLayerCmsFieldBinding, extractBlockText } from '@/lib/layer-utils'; +import { canHaveChildren, getLayerIcon, getLayerName, getCollectionVariable, isTextContentLayer, isRichTextLayer, hasRichTextContent, getRichTextSublayers, getTextStyleSublayers, canMoveLayer, updateLayerProps, filterDisabledSliderLayers, getLayerCmsFieldBinding, extractBlockText } from '@/lib/layer-utils'; import { getBlockName } from '@/lib/templates/blocks'; import { MULTI_ASSET_COLLECTION_ID } from '@/lib/collection-field-utils'; import { hasStyleOverrides } from '@/lib/layer-style-utils'; @@ -252,7 +252,7 @@ const LayerRow = React.memo(function LayerRow({ // Component instances should not show children in the tree (unless editing master) // Children can only be edited via "Edit master component" const shouldHideChildren = isComponentInstance && !editingComponentId; - const hasContentSublayers = isRichTextLayer(node.layer) && getRichTextSublayers(node.layer).length > 0; + const hasContentSublayers = hasRichTextContent(node.layer); const hasStyleSublayers = isTextContentLayer(node.layer) && getTextStyleSublayers(node.layer).length > 0; const hasSublayers = hasContentSublayers || hasStyleSublayers; const effectiveHasChildren = (hasChildren && !shouldHideChildren) || hasSublayers; @@ -1883,7 +1883,7 @@ export default function LayersTree({ hasVisibleChildren = node.canHaveChildren && !collapsedIds.has(node.id); } else { // Real layer nodes: check actual children and sublayer presence - const hasAnySublayers = (isRichTextLayer(node.layer) && getRichTextSublayers(node.layer).length > 0) + const hasAnySublayers = hasRichTextContent(node.layer) || (isTextContentLayer(node.layer) && getTextStyleSublayers(node.layer).length > 0); hasVisibleChildren = (!collapsedIds.has(node.id)) && ( !!(node.layer.children && node.layer.children.length > 0) || hasAnySublayers diff --git a/lib/layer-utils.ts b/lib/layer-utils.ts index 2bb63945..02d0ffcf 100644 --- a/lib/layer-utils.ts +++ b/lib/layer-utils.ts @@ -624,6 +624,21 @@ function extractInlineMarks(block: any): string[] { * When a CMS field is bound, pass the resolved CMS content via `cmsContent` * so sublayers reflect the actual CMS item data. */ +/** + * Check if a richText layer has content blocks (either its own or via CMS binding). + * Used for determining collapsibility in the layers tree without requiring resolved CMS data. + */ +export function hasRichTextContent(layer: Layer): boolean { + if (!isRichTextLayer(layer)) return false; + const textVar = layer.variables?.text; + if (textVar?.type !== 'dynamic_rich_text') return false; + const layerDoc = (textVar.data as any)?.content; + if (!layerDoc?.content || !Array.isArray(layerDoc.content)) return false; + const binding = getCmsFieldBinding(layerDoc); + if (binding) return true; + return layerDoc.content.some((block: any) => block.type !== 'paragraph' || block.content?.length); +} + export function getRichTextSublayers(layer: Layer, cmsContent?: any): RichTextSublayer[] { const textVar = layer.variables?.text; if (textVar?.type !== 'dynamic_rich_text') return []; diff --git a/lib/text-format-utils.ts b/lib/text-format-utils.ts index ddca2fc0..141c1bab 100644 --- a/lib/text-format-utils.ts +++ b/lib/text-format-utils.ts @@ -435,8 +435,8 @@ function renderTextNode( /** * Render nested rich text content from a Tiptap JSON structure. * Used when a rich_text CMS field is inserted as an inline variable. - * Delegates to renderBlock with useSpanForParagraphs=true since this - * content is always nested inside another element. + * When useSpanForParagraphs is true (default), block elements render as spans + * to avoid invalid HTML nesting inside restrictive tags like

or

. */ function renderNestedRichTextContent( richTextValue: any, @@ -451,6 +451,7 @@ function renderNestedRichTextContent( components?: Component[], renderComponentBlock?: RenderComponentBlockFn, ancestorComponentIds?: Set, + useSpanForParagraphs = true, ): React.ReactNode[] { if (!richTextValue) { return []; @@ -471,7 +472,7 @@ function renderNestedRichTextContent( if (parsed.type === 'doc' && Array.isArray(parsed.content)) { return parsed.content.map((block: any, blockIdx: number) => - renderBlock(block, blockIdx, collectionItemData, pageCollectionItemData, textStyles, true, isEditMode, linkContext, timezone, layerDataMap, components, renderComponentBlock, ancestorComponentIds) + renderBlock(block, blockIdx, collectionItemData, pageCollectionItemData, textStyles, useSpanForParagraphs, isEditMode, linkContext, timezone, layerDataMap, components, renderComponentBlock, ancestorComponentIds) ).filter(Boolean); } @@ -493,6 +494,7 @@ function renderInlineContent( components?: Component[], renderComponentBlock?: RenderComponentBlockFn, ancestorComponentIds?: Set, + useSpanForParagraphs = true, ): React.ReactNode[] { return content.flatMap((node, idx) => { const key = `node-${idx}`; @@ -530,6 +532,7 @@ function renderInlineContent( components, renderComponentBlock, ancestorComponentIds, + useSpanForParagraphs, ); } } @@ -692,7 +695,7 @@ function renderBlock( ); const tag = hasBlockContent ? 'div' : useSpanForParagraphs ? 'span' : 'p'; - return React.createElement(tag, paragraphProps, ...renderInlineContent(block.content, collectionItemData, pageCollectionItemData, textStyles, isEditMode, linkContext, timezone, layerDataMap, components, renderComponentBlock, ancestorComponentIds)); + return React.createElement(tag, paragraphProps, ...renderInlineContent(block.content, collectionItemData, pageCollectionItemData, textStyles, isEditMode, linkContext, timezone, layerDataMap, components, renderComponentBlock, ancestorComponentIds, useSpanForParagraphs)); } if (block.type === 'heading') { @@ -711,7 +714,7 @@ function renderBlock( if (isEditMode) { headingProps['data-style'] = styleKey; } - return React.createElement(tag, headingProps, ...renderInlineContent(block.content, collectionItemData, pageCollectionItemData, textStyles, isEditMode, linkContext, timezone, layerDataMap, components, renderComponentBlock, ancestorComponentIds)); + return React.createElement(tag, headingProps, ...renderInlineContent(block.content, collectionItemData, pageCollectionItemData, textStyles, isEditMode, linkContext, timezone, layerDataMap, components, renderComponentBlock, ancestorComponentIds, useSpanForParagraphs)); } if (block.type === 'bulletList') { @@ -917,7 +920,8 @@ export function renderRichText( paragraph.content[0].attrs?.variable?.data?.field_type === 'rich_text'; if (hasSoleRichTextVariable) { - const inlineContent = renderInlineContent(paragraph.content, collectionItemData, pageCollectionItemData, textStyles, isEditMode, linkContext, timezone, layerDataMap, components, renderComponentBlock, ancestorComponentIds); + const nestedUseSpans = isSimpleTextElement ? true : useSpanForParagraphs; + const inlineContent = renderInlineContent(paragraph.content, collectionItemData, pageCollectionItemData, textStyles, isEditMode, linkContext, timezone, layerDataMap, components, renderComponentBlock, ancestorComponentIds, nestedUseSpans); return Array.isArray(inlineContent) ? inlineContent : [inlineContent]; } @@ -928,7 +932,7 @@ export function renderRichText( } return null; } - const inlineContent = renderInlineContent(paragraph.content, collectionItemData, pageCollectionItemData, textStyles, isEditMode, linkContext, timezone, layerDataMap, components, renderComponentBlock, ancestorComponentIds); + const inlineContent = renderInlineContent(paragraph.content, collectionItemData, pageCollectionItemData, textStyles, isEditMode, linkContext, timezone, layerDataMap, components, renderComponentBlock, ancestorComponentIds, useSpanForParagraphs); if (isEditMode && !isSimpleTextElement) { const paragraphClass = textStyles?.paragraph?.classes ?? DEFAULT_TEXT_STYLES.paragraph?.classes ?? ''; const children = Array.isArray(inlineContent) ? inlineContent : [inlineContent];