From 3160be60157aedf369dbca36d66efed70faa8019 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Thu, 23 Apr 2026 23:40:14 +0100 Subject: [PATCH 1/4] Preserve whitespace between sibling elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Treat whitespace-only text nodes between element siblings as part of the previous element instead of creating a separate text node. generateFragmentFromHtml now detects when a text node consisting only of space appears between two elements and appends that space to the previous instance's last text child (if present), avoiding creation of a standalone text child. Added a regression test in plugin-html.test.tsx to verify pasted HTML like ` text` preserves the space and results in two span instances (no separate text node), ensuring text-content control remains correct in the settings panel. --- .../shared/copy-paste/plugin-html.test.tsx | 54 +++++++++++++++++++ apps/builder/app/shared/html.ts | 30 +++++++++-- 2 files changed, 81 insertions(+), 3 deletions(-) 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..d51e1975215c 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,57 @@ 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 span1Id = (pastedDiv?.children[0] as any)?.value; + const span2Id = (pastedDiv?.children[1] as any)?.value; + + const span1 = $instances.get().get(span1Id); + const span2 = $instances.get().get(span2Id); + + 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/html.ts b/apps/builder/app/shared/html.ts index 56f179e7d17b..e8aa004fc7b6 100644 --- a/apps/builder/app/shared/html.ts +++ b/apps/builder/app/shared/html.ts @@ -902,10 +902,34 @@ export const generateFragmentFromHtml = ( } } if (defaultTreeAdapter.isTextNode(childNode)) { - // trim spaces around rich text - // do not for code + // For whitespace-only text nodes between elements, attach to previous element + // instead of creating a separate text node (preserves space but avoids breaking rich-text detection) if (spaceRegex.test(childNode.value) && node.tagName !== "code") { - if (index === 0 || index === node.childNodes.length - 1) { + const prevChild = index > 0 ? node.childNodes[index - 1] : undefined; + const nextChild = + index < node.childNodes.length - 1 + ? node.childNodes[index + 1] + : undefined; + const prevIsElement = + prevChild && defaultTreeAdapter.isElementNode(prevChild); + const nextIsElement = + nextChild && defaultTreeAdapter.isElementNode(nextChild); + + // If whitespace is between two elements, attach it to the previous element + if (prevIsElement && nextIsElement && instance.children.length > 0) { + const lastChild = instance.children.at(-1); + if (lastChild?.type === "id") { + // Find the previous instance and append space to its last text child + 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 += " "; + continue; + } + } + } continue; } } From 9175838fc8a5d04cd84ac348010770a1446ff3de Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Sat, 25 Apr 2026 17:30:33 +0100 Subject: [PATCH 2/4] Update plugin-html.test.tsx --- apps/builder/app/shared/copy-paste/plugin-html.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 d51e1975215c..d30ea8316806 100644 --- a/apps/builder/app/shared/copy-paste/plugin-html.test.tsx +++ b/apps/builder/app/shared/copy-paste/plugin-html.test.tsx @@ -100,8 +100,10 @@ test("skip whitespace-only text nodes between element siblings", async () => { expect(pastedDiv?.children.some((c) => c.type === "text")).toBe(false); // Verify the space is preserved in one of the spans' text content - const span1Id = (pastedDiv?.children[0] as any)?.value; - const span2Id = (pastedDiv?.children[1] as any)?.value; + 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 = $instances.get().get(span1Id); const span2 = $instances.get().get(span2Id); From 47bb89fc14761483db3212fd45c61c8913aae07a Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Sat, 25 Apr 2026 17:11:19 +0000 Subject: [PATCH 3/4] fix: restore correct whitespace trimming and fix space-between-siblings logic - Restore boundary whitespace skipping (index === 0 or last) that was removed - Gate the space-attach-to-prev logic to rich-text contexts only (hasNonRichTextContent check) so select/table/etc. are unaffected - Track spaceAttachedToPrev flag so the next sibling element's leading space is not double-stripped - Fix `as any` lint errors in plugin-html.test.tsx with proper type narrowing - Update test expectations to match new behavior (space appended to prev element's text child)" --- .../copy-paste/plugin-markdown.test.tsx | 2 +- apps/builder/app/shared/html.test.tsx | 2 +- apps/builder/app/shared/html.ts | 37 ++++++++++++------- 3 files changed, 25 insertions(+), 16 deletions(-) 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 e8aa004fc7b6..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,30 +905,36 @@ export const generateFragmentFromHtml = ( } } if (defaultTreeAdapter.isTextNode(childNode)) { - // For whitespace-only text nodes between elements, attach to previous element - // instead of creating a separate text node (preserves space but avoids breaking rich-text detection) + // trim spaces around rich text, do not for code if (spaceRegex.test(childNode.value) && node.tagName !== "code") { - const prevChild = index > 0 ? node.childNodes[index - 1] : undefined; - const nextChild = - index < node.childNodes.length - 1 - ? node.childNodes[index + 1] - : undefined; - const prevIsElement = - prevChild && defaultTreeAdapter.isElementNode(prevChild); - const nextIsElement = - nextChild && defaultTreeAdapter.isElementNode(nextChild); + // 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); - // If whitespace is between two elements, attach it to the previous element - if (prevIsElement && nextIsElement && instance.children.length > 0) { + // 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") { - // Find the previous instance and append space to its last text child 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; } } From 76aa574d43ee687c707c59d7334a35bc06538c96 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Sat, 25 Apr 2026 17:37:08 +0000 Subject: [PATCH 4/4] fix: guard span id lookup against undefined to satisfy typecheck" --- apps/builder/app/shared/copy-paste/plugin-html.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d30ea8316806..88c38206cc10 100644 --- a/apps/builder/app/shared/copy-paste/plugin-html.test.tsx +++ b/apps/builder/app/shared/copy-paste/plugin-html.test.tsx @@ -105,8 +105,8 @@ test("skip whitespace-only text nodes between element siblings", async () => { const span1Id = child0?.type === "id" ? child0.value : undefined; const span2Id = child1?.type === "id" ? child1.value : undefined; - const span1 = $instances.get().get(span1Id); - const span2 = $instances.get().get(span2Id); + 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 : "";