diff --git a/apps/builder/app/shared/copy-paste/plugin-html.test.tsx b/apps/builder/app/shared/copy-paste/plugin-html.test.tsx index b8fd1ed6bc4c..88c38206cc10 100644 --- a/apps/builder/app/shared/copy-paste/plugin-html.test.tsx +++ b/apps/builder/app/shared/copy-paste/plugin-html.test.tsx @@ -63,3 +63,59 @@ test("ignore html without any tags", async () => { expect(await html.onPaste?.(`It works`)).toEqual(false); expect($instances.get()).toEqual(data.instances); }); + +test("skip whitespace-only text nodes between element siblings", async () => { + const data = renderData( + + + + ); + $project.set({ id: "" } as Project); + $instances.set(data.instances); + $pages.set( + createDefaultPages({ rootInstanceId: "bodyId", homePageId: "pageId" }) + ); + $awareness.set({ pageId: "pageId", instanceSelector: ["divId", "bodyId"] }); + + // Regression test: whitespace between elements should not create separate text nodes + // but the space should be preserved as part of one of the adjacent span instances + // This ensures the second span gets text-content control in settings panel + expect( + await html.onPaste?.(`
text
`) + ).toEqual(true); + + const instances = Array.from($instances.get().values()).filter( + (i) => i.id !== "bodyId" && i.id !== "divId" + ); + + const divs = instances.filter((i) => i.tag === "div"); + expect(divs.length).toBeGreaterThan(0); + + const pastedDiv = divs[0]; + expect(pastedDiv?.children).toHaveLength(2); + + // Both children should be element ids (the two spans), not a text node for the space + expect(pastedDiv?.children[0].type).toBe("id"); + expect(pastedDiv?.children[1].type).toBe("id"); + expect(pastedDiv?.children.some((c) => c.type === "text")).toBe(false); + + // Verify the space is preserved in one of the spans' text content + const child0 = pastedDiv?.children[0]; + const child1 = pastedDiv?.children[1]; + const span1Id = child0?.type === "id" ? child0.value : undefined; + const span2Id = child1?.type === "id" ? child1.value : undefined; + + const span1 = span1Id ? $instances.get().get(span1Id) : undefined; + const span2 = span2Id ? $instances.get().get(span2Id) : undefined; + + const span1Text = + span1?.children[0]?.type === "text" ? span1.children[0].value : ""; + const span2Text = + span2?.children[0]?.type === "text" ? span2.children[0].value : ""; + + // Space should be preserved as part of one of the spans, not lost + const combinedText = span1Text + span2Text; + expect(combinedText).toContain("✓"); + expect(combinedText).toContain("text"); + expect(combinedText).toContain(" "); +}); diff --git a/apps/builder/app/shared/copy-paste/plugin-markdown.test.tsx b/apps/builder/app/shared/copy-paste/plugin-markdown.test.tsx index 14831acec914..6a56db80f25c 100644 --- a/apps/builder/app/shared/copy-paste/plugin-markdown.test.tsx +++ b/apps/builder/app/shared/copy-paste/plugin-markdown.test.tsx @@ -196,7 +196,7 @@ test("preserve spaces between strong and em", () => { renderTemplate( <> - One{" "} + {"One "} two {" text"} diff --git a/apps/builder/app/shared/html.test.tsx b/apps/builder/app/shared/html.test.tsx index 66ba2e4518d9..4471ffcd8cb0 100644 --- a/apps/builder/app/shared/html.test.tsx +++ b/apps/builder/app/shared/html.test.tsx @@ -219,7 +219,7 @@ test("collapse any spacing characters inside rich text", () => { ).toEqual( renderTemplate( - line{" "} + {"line "} another line text ) diff --git a/apps/builder/app/shared/html.ts b/apps/builder/app/shared/html.ts index 56f179e7d17b..0577c249dae8 100644 --- a/apps/builder/app/shared/html.ts +++ b/apps/builder/app/shared/html.ts @@ -887,13 +887,16 @@ export const generateFragmentFromHtml = ( } } } + let spaceAttachedToPrev = false; for (let index = 0; index < node.childNodes.length; index += 1) { const childNode = node.childNodes[index]; if (defaultTreeAdapter.isElementNode(childNode)) { const lastChild = instance.children.at(-1); const nextPreserveLeadingSpace = + !spaceAttachedToPrev && instance.children.length > 0 && !(lastChild?.type === "text" && lastChild.value.endsWith(" ")); + spaceAttachedToPrev = false; const child = convertElementToInstance(childNode, { preserveLeadingSpace: nextPreserveLeadingSpace, }); @@ -902,12 +905,42 @@ export const generateFragmentFromHtml = ( } } if (defaultTreeAdapter.isTextNode(childNode)) { - // trim spaces around rich text - // do not for code + // trim spaces around rich text, do not for code if (spaceRegex.test(childNode.value) && node.tagName !== "code") { + // Skip whitespace at start or end of parent if (index === 0 || index === node.childNodes.length - 1) { continue; } + const prevChild = node.childNodes[index - 1]; + const nextChild = node.childNodes[index + 1]; + const prevIsElement = defaultTreeAdapter.isElementNode(prevChild); + const nextIsElement = defaultTreeAdapter.isElementNode(nextChild); + + // In rich-text contexts, attach whitespace between two sibling elements + // to the previous element instead of creating a separate text node. + // This avoids a standalone space child that would prevent the next element + // from being recognized as the last child for text-content control. + if ( + prevIsElement && + nextIsElement && + !hasNonRichTextContent && + instance.children.length > 0 + ) { + const lastChild = instance.children.at(-1); + if (lastChild?.type === "id") { + const prevInstanceId = lastChild.value; + const prevInstance = instances.get(prevInstanceId); + if (prevInstance && prevInstance.children.length > 0) { + const prevLastChild = prevInstance.children.at(-1); + if (prevLastChild?.type === "text") { + prevLastChild.value += " "; + spaceAttachedToPrev = true; + continue; + } + } + } + continue; + } } let child: Instance["children"][number] = { type: "text",