Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/builder/app/shared/copy-paste/plugin-html.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ws.element ws:tag="body" ws:id="bodyId">
<ws.element ws:tag="div" ws:id="divId"></ws.element>
</ws.element>
);
$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?.(`<div><span>✓</span> <span>text</span></div>`)
).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(" ");
});
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ test("preserve spaces between strong and em", () => {
renderTemplate(
<>
<ws.element ws:tag="p">
<ws.element ws:tag="strong">One</ws.element>{" "}
<ws.element ws:tag="strong">{"One "}</ws.element>
<ws.element ws:tag="em">two</ws.element>
{" text"}
</ws.element>
Expand Down
2 changes: 1 addition & 1 deletion apps/builder/app/shared/html.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ test("collapse any spacing characters inside rich text", () => {
).toEqual(
renderTemplate(
<ws.element ws:tag="div">
<ws.element ws:tag="i">line</ws.element>{" "}
<ws.element ws:tag="i">{"line "}</ws.element>
<ws.element ws:tag="b">another line</ws.element> text
</ws.element>
)
Expand Down
37 changes: 35 additions & 2 deletions apps/builder/app/shared/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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",
Expand Down
Loading