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
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;
Comment thread
kirtesh-cstk marked this conversation as resolved.
}
Expand Down
Loading