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
819 changes: 734 additions & 85 deletions package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ describe("StartEditingButtonComponent", () => {
test("should update href with current URL when mouse enters button", async () => {
Object.defineProperty(window, "location", {
value: new URL("http://localhost:3000"),
configurable: true,
writable: true,
});

const { getByTestId } = await asyncRender(
Expand All @@ -120,6 +122,7 @@ describe("StartEditingButtonComponent", () => {

Object.defineProperty(window, "location", {
value: new URL("http://localhost:3000/about"),
configurable: true,
writable: true,
});

Expand All @@ -135,6 +138,8 @@ describe("StartEditingButtonComponent", () => {
test("should update href with current URL when button is focused", async () => {
Object.defineProperty(window, "location", {
value: new URL("http://localhost:3000"),
configurable: true,
writable: true,
});

const { getByTestId } = await asyncRender(
Expand All @@ -145,10 +150,11 @@ describe("StartEditingButtonComponent", () => {

Object.defineProperty(window, "location", {
value: new URL("http://localhost:3000/contact"),
configurable: true,
writable: true,
});

fireEvent.focus(button);
button.focus();

const updatedHref = button.getAttribute("href");
expect(updatedHref).not.toBe(initialHref);
Expand Down
121 changes: 120 additions & 1 deletion src/visualBuilder/listeners/__test__/mouseClick.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import handleBuilderInteraction from "../mouseClick";
import { VisualBuilder } from "../../index";
import { FieldSchemaMap } from "../../utils/fieldSchemaMap";
Expand Down Expand Up @@ -250,3 +250,122 @@ describe("handleBuilderInteraction — pauseFeedback guard", () => {
expect(generateThread).toHaveBeenCalled();
});
});

describe("handleBuilderInteraction — alt+click on anchor", () => {
const originalLocation = window.location;

beforeEach(() => {
Object.defineProperty(window, "location", {
value: { href: "" },
writable: true,
});
});

afterEach(() => {
Object.defineProperty(window, "location", {
value: originalLocation,
writable: true,
});
});

it("prevents default/propagation and drives navigation itself instead of relying on the native click", async () => {
document.body.innerHTML = "";
const anchor = document.createElement("a");
anchor.href = "https://example.com/blog#anchor";
document.body.appendChild(anchor);

const params = makeParams(anchor);
Object.defineProperty(params.event, "altKey", { value: true });
const preventDefaultSpy = vi.spyOn(params.event, "preventDefault");
const stopPropagationSpy = vi.spyOn(params.event, "stopPropagation");

await handleBuilderInteraction(params);

expect(preventDefaultSpy).toHaveBeenCalled();
expect(stopPropagationSpy).toHaveBeenCalled();
expect(window.location.href).toBe("https://example.com/blog#anchor");
expect(getCsDataOfElement).not.toHaveBeenCalled();
});

it("does nothing on alt+click when the target isn't an anchor", async () => {
const editableElement = makeEditableElement();
const params = makeParams(editableElement);
Object.defineProperty(params.event, "altKey", { value: true });
const preventDefaultSpy = vi.spyOn(params.event, "preventDefault");

await handleBuilderInteraction(params);

expect(preventDefaultSpy).not.toHaveBeenCalled();
expect(window.location.href).toBe("");
expect(getCsDataOfElement).not.toHaveBeenCalled();
});

it("opens target=_blank links (RTE 'open in new tab') in a new tab instead of hijacking the iframe", async () => {
document.body.innerHTML = "";
const anchor = document.createElement("a");
anchor.href = "https://example.com/other-entry";
anchor.target = "_blank";
document.body.appendChild(anchor);

const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
const params = makeParams(anchor);
Object.defineProperty(params.event, "altKey", { value: true });

await handleBuilderInteraction(params);

expect(openSpy).toHaveBeenCalledWith(
"https://example.com/other-entry",
"_blank",
"noopener,noreferrer"
);
expect(window.location.href).toBe("");
openSpy.mockRestore();
});

it("resolves the anchor ancestor when the click lands on nested formatted text inside the link", async () => {
document.body.innerHTML = "";
const anchor = document.createElement("a");
anchor.href = "https://example.com/bold-link";
const bold = document.createElement("strong");
bold.textContent = "click me";
anchor.appendChild(bold);
document.body.appendChild(anchor);

const params = makeParams(bold);
Object.defineProperty(params.event, "altKey", { value: true });
const preventDefaultSpy = vi.spyOn(params.event, "preventDefault");

await handleBuilderInteraction(params);

expect(preventDefaultSpy).toHaveBeenCalled();
expect(window.location.href).toBe("https://example.com/bold-link");
});

it("blocks unsafe url schemes like javascript: on alt+click", async () => {
document.body.innerHTML = "";
const anchor = document.createElement("a");
anchor.href = "javascript:alert(1)";
document.body.appendChild(anchor);

const params = makeParams(anchor);
Object.defineProperty(params.event, "altKey", { value: true });

await handleBuilderInteraction(params);

expect(window.location.href).toBe("");
});

it("allows relative hrefs (resolve to the page's own http/https scheme)", async () => {
document.body.innerHTML = "";
const anchor = document.createElement("a");
anchor.href = "/other-entry";
document.body.appendChild(anchor);

const params = makeParams(anchor);
Object.defineProperty(params.event, "altKey", { value: true });

await handleBuilderInteraction(params);

expect(window.location.href).toBe(anchor.href);
});
});
18 changes: 16 additions & 2 deletions src/visualBuilder/listeners/mouseClick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermiss
import { isCustomFieldMultipleInstance } from "../utils/isCustomFieldMultipleInstance";
import { getParentCslp, getWholeFieldElement } from "../utils/getWholeFieldElement";

const SAFE_URL_SCHEMES = new Set(["http:", "https:", "mailto:", "tel:"]);

export type HandleBuilderInteractionParams = Omit<
EventListenerHandlerParams,
"eventDetails" | "customCursor"
Expand Down Expand Up @@ -84,7 +86,9 @@ export async function handleBuilderInteraction(
params: HandleBuilderInteractionParams
): Promise<void> {
const eventTarget = params.event.target as HTMLElement | null;
const isAnchorElement = eventTarget instanceof HTMLAnchorElement;
// resolve nearest anchor ancestor, not just an exact tag match
const anchorElement = eventTarget?.closest("a") ?? null;
const isAnchorElement = anchorElement !== null;
const elementHasCslp =
eventTarget &&
(eventTarget.hasAttribute("data-cslp") ||
Expand Down Expand Up @@ -115,10 +119,20 @@ export async function handleBuilderInteraction(
return;
}

// Alt+click on a link: navigate explicitly, don't rely on the native
// click (browsers alt-click anchors as a download, not a navigation)
if (params.event.altKey) {
if (isAnchorElement) {
if (anchorElement) {
const { href, target, protocol } = anchorElement;
params.event.preventDefault();
params.event.stopPropagation();
if (href && SAFE_URL_SCHEMES.has(protocol)) {
if (target === "_blank") {
window.open(href, "_blank", "noopener,noreferrer");
} else {
window.location.href = href;
}
}
}
return;
}
Expand Down
15 changes: 0 additions & 15 deletions src/visualBuilder/listeners/mouseHover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,21 +303,6 @@ const throttledMouseHover = throttle(async (params: HandleMouseHoverParams) => {
}

if (params.customCursor) {
const elementUnderCursor = document.elementFromPoint(
params.event.clientX,
params.event.clientY
);
if (elementUnderCursor) {
if (
elementUnderCursor.nodeName === "A" ||
elementUnderCursor.nodeName === "BUTTON"
) {
elementUnderCursor.classList.add(
visualBuilderStyles()["visual-builder__no-cursor-style"]
);
}
}

if (config?.collab.enable && config?.collab.isFeedbackMode) {
collabCustomCursor(params.customCursor);
handleCursorPosition(params.event, params.customCursor);
Expand Down
32 changes: 32 additions & 0 deletions src/visualBuilder/utils/__test__/getCsDataOfElement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,38 @@ describe("getCsDataOfElement", () => {
});
});

test("does not pierce the visual builder's own UI (e.g. the field toolbar) even when fallback is enabled", () => {
Config.set("overlayPropagation", { enable: true });

// clicks on the SDK's own toolbar land on elements inside the
// visual builder container and must never resolve to the canvas
// field underneath
const vbContainer = document.createElement("div");
vbContainer.classList.add("visual-builder__container");
const toolbarButton = document.createElement("button");
vbContainer.appendChild(toolbarButton);
document.body.appendChild(vbContainer);

const toolbarClick = new MouseEvent("click", {
bubbles: true,
cancelable: true,
clientX: 100,
clientY: 100,
});
Object.defineProperty(toolbarClick, "target", {
value: toolbarButton,
writable: false,
});
(
document.elementsFromPoint as ReturnType<typeof vi.fn>
).mockReturnValue([toolbarButton, vbContainer, cslpEl]);

const result = getCsDataOfElement(toolbarClick);

expect(result).toBeUndefined();
expect(document.elementsFromPoint).not.toHaveBeenCalled();
});

test("returns undefined when fallback is enabled but no element under the cursor has data-cslp", () => {
Config.set("overlayPropagation", { enable: true });
const otherBlocker = document.createElement("div");
Expand Down
13 changes: 12 additions & 1 deletion src/visualBuilder/utils/getCsDataOfElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,18 @@ export function getCsDataOfElement(
let editableElement: Element | null =
targetElement.closest("[data-cslp]");

if (!editableElement && Config.get().overlayPropagation.enable) {
// Clicks on the visual builder's own UI (field toolbar, overlays, add
// buttons) must never resolve to the canvas field underneath them. The
// elementsFromPoint fallback exists to pierce the website's own empty
// overlapping elements, not the SDK's chrome: piercing the toolbar made
// the edit (pencil) button double as a click on the field it covered.
// The closest() check runs last so hover events only pay for it when
// the fallback is enabled and no field matched.
if (
!editableElement &&
Config.get().overlayPropagation.enable &&
!targetElement.closest(".visual-builder__container")
) {
const stack = document.elementsFromPoint(
event.clientX,
event.clientY
Expand Down
10 changes: 7 additions & 3 deletions src/visualBuilder/visualBuilder.style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,13 @@ export function visualBuilderStyles() {
`,
"visual-builder__default-cursor--disabled": css`
cursor: none;
/* links/buttons carry their own cursor:pointer — suppress it too
while the custom cursor is active. Scoped to this body class so
it auto-reverts when the class is removed */
& a,
& button {
cursor: none !important;
}
`,
"visual-builder__draft-field": css`
outline: 2px dashed #eb5646;
Expand Down Expand Up @@ -819,9 +826,6 @@ export function visualBuilderStyles() {
margin-bottom: 4px;
}
`,
"visual-builder__no-cursor-style": css`
cursor: none !important;
`,
"visual-builder__field-toolbar-container": css`
display: flex;
flex-direction: column-reverse;
Expand Down
Loading