diff --git a/src/visualBuilder/listeners/__test__/mouseClick.test.ts b/src/visualBuilder/listeners/__test__/mouseClick.test.ts index cb65241a..cb41cff9 100644 --- a/src/visualBuilder/listeners/__test__/mouseClick.test.ts +++ b/src/visualBuilder/listeners/__test__/mouseClick.test.ts @@ -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"; @@ -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); + }); +}); diff --git a/src/visualBuilder/listeners/mouseClick.ts b/src/visualBuilder/listeners/mouseClick.ts index 2f34550f..b874a198 100644 --- a/src/visualBuilder/listeners/mouseClick.ts +++ b/src/visualBuilder/listeners/mouseClick.ts @@ -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" @@ -84,7 +86,9 @@ export async function handleBuilderInteraction( params: HandleBuilderInteractionParams ): Promise { 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") || @@ -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; }