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",