Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ You can configure the SDK using the following options:
- [`mode`](docs/live-preview-configs.md#mode) (`preview` vs `builder`)
- [`editButton`](docs/live-preview-configs.md#editbutton)
- [`editInVisualBuilderButton`](docs/live-preview-configs.md#editinvisualbuilderbutton) (Start Editing outside Visual Editor)
- [`overlayPropagation`](docs/live-preview-configs.md#overlaypropagation) (opt-in fallback to pierce blocking sibling overlays during hover/click detection)
- [`cleanCslpOnProduction`](docs/live-preview-configs.md#cleancslponproduction)
- [`stackDetails`](docs/live-preview-configs.md#stackdetails) ([`apiKey`](docs/live-preview-configs.md#apikey), [`environment`](docs/live-preview-configs.md#environment))
- [`clientUrlParams`](docs/live-preview-configs.md#clienturlparams) — [NA](docs/live-preview-configs.md#na-config) / [EU](docs/live-preview-configs.md#eu-config)
Expand Down
30 changes: 30 additions & 0 deletions docs/live-preview-configs.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,36 @@ The editInVisualBuilderButton object contains two keys:
The user can place the "Start Editing" button in four predefined positions
top-left, top-right, bottom-left, and bottom-right.

### `overlayPropagation`

The `overlayPropagation` object enables hover/click detection to pierce through sibling elements that visually overlap a `data-cslp` field but intercept the mouse event before it reaches the field. This is an opt-in fallback intended for apps where unrelated DOM elements (for example, empty CSS-grid spacer cells in a multi-column layout) sit on top of `data-cslp` containers and block the SDK from detecting the field on hover or click.

When the flag is **enabled** and the standard event path contains no `data-cslp` element, the SDK falls back to `document.elementsFromPoint(clientX, clientY)` and selects the topmost element in that stack that carries a `data-cslp` attribute. When the flag is **disabled** (default), behavior is unchanged.

This flag covers both pipelines:

- **Visual Builder** — hover outline and click-to-focus detection (`getCsDataOfElement`)
- **Standalone Live Preview Edit button** — the floating Edit button outside Visual Builder (`addCslpOutline`); a companion throttled `mousemove` listener ensures the button tracks the cursor while it moves within a blocking overlay

The `overlayPropagation` object contains one key:

1. #### `enable`
| type | default | optional |
| ------- | ------- | -------- |
| boolean | false | yes |

Set to `true` to activate the `elementsFromPoint` fallback for hover and click detection. The fallback runs only when the standard `closest("[data-cslp]")` lookup returns `null`, so there is no performance impact on the normal path.

**For example:**
```ts
ContentstackLivePreview.init({
...
overlayPropagation: {
enable: true,
}
});
```

### `cleanCslpOnProduction`

| type | default | optional |
Expand Down
1 change: 1 addition & 0 deletions main.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ You can configure the SDK using the following options:
- [`mode`](docs/live-preview-configs.md#mode) (`preview` vs `builder`)
- [`editButton`](docs/live-preview-configs.md#editbutton)
- [`editInVisualBuilderButton`](docs/live-preview-configs.md#editinvisualbuilderbutton) (Start Editing outside Visual Editor)
- [`overlayPropagation`](docs/live-preview-configs.md#overlaypropagation) (opt-in fallback to pierce blocking sibling overlays during hover/click detection)
- [`cleanCslpOnProduction`](docs/live-preview-configs.md#cleancslponproduction)
- [`stackDetails`](docs/live-preview-configs.md#stackdetails) ([`apiKey`](docs/live-preview-configs.md#apikey), [`environment`](docs/live-preview-configs.md#environment))
- [`clientUrlParams`](docs/live-preview-configs.md#clienturlparams) — [NA](docs/live-preview-configs.md#na-config) / [EU](docs/live-preview-configs.md#eu-config)
Expand Down
26 changes: 26 additions & 0 deletions src/__test__/data/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,29 @@ export const mockMultipleFileFieldSchema: ISchemaFieldMap = {
non_localizable: false,
unique: false,
};

export const mockMultipleCustomFieldSchema: ISchemaFieldMap = {
extension_uid: "test_extension_uid",
field_metadata: { extension: true },
config: {},
data_type: "number",
display_name: "Custom Field",
uid: "custom_field",
mandatory: false,
multiple: true,
non_localizable: false,
unique: false,
} as unknown as ISchemaFieldMap;

export const mockSingleCustomFieldSchema: ISchemaFieldMap = {
extension_uid: "test_extension_uid",
field_metadata: { extension: true },
config: {},
data_type: "number",
display_name: "Custom Field",
uid: "custom_field",
mandatory: false,
multiple: false,
non_localizable: false,
unique: false,
} as unknown as ISchemaFieldMap;
36 changes: 36 additions & 0 deletions src/configManager/__test__/handleUserConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,42 @@ describe("handleInitData()", () => {
expect(config.editButton.includeByQueryParameter).toBe(false);
});
});

describe("handleInitData() - overlayPropagation configuration", () => {
let config: DeepSignal<IConfig>;

beforeEach(() => {
Config.reset();
config = Config.get();
});

afterAll(() => {
Config.reset();
});

test("should default overlayPropagation.enable to false when not provided", () => {
handleInitData({});
expect(config.overlayPropagation.enable).toBe(false);
});

test("should set overlayPropagation.enable from initData when true", () => {
const initData: Partial<IInitData> = {
overlayPropagation: { enable: true },
};

handleInitData(initData);
expect(config.overlayPropagation.enable).toBe(true);
});

test("should keep overlayPropagation.enable false when initData sets it false explicitly", () => {
const initData: Partial<IInitData> = {
overlayPropagation: { enable: false },
};

handleInitData(initData);
expect(config.overlayPropagation.enable).toBe(false);
});
});
});

describe("handleInitData() - enableLivePreviewOutsideIframe", () => {
Expand Down
6 changes: 6 additions & 0 deletions src/configManager/config.default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export function getUserInitData(): IInitData {
enable: true,
position: "bottom-right"
},
overlayPropagation: {
enable: false,
},

mode: "preview",

Expand Down Expand Up @@ -59,6 +62,9 @@ export function getDefaultConfig(): IConfig {
enable: true,
position: "bottom-right"
},
overlayPropagation: {
enable: false,
},

hash: "",
mode: 1,
Expand Down
8 changes: 7 additions & 1 deletion src/configManager/handleUserConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,19 @@ export const handleInitData = (initData: Partial<IInitData>): void => {
initData.editInVisualBuilderButton?.enable ??
stackSdk.live_preview?.editInVisualBuilderButton?.enable ??
config.editInVisualBuilderButton.enable,
position:
position:
initData.editInVisualBuilderButton?.position ??
stackSdk.live_preview?.position ??
config.editInVisualBuilderButton.position ??
"bottom-right",
})

Config.set("overlayPropagation", {
enable:
initData.overlayPropagation?.enable ??
config.overlayPropagation.enable,
});

Config.set(
"enableLivePreviewOutsideIframe",
initData.enableLivePreviewOutsideIframe ??
Expand Down
50 changes: 34 additions & 16 deletions src/cslp/cslpdata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,23 @@ function getMultipleFieldMetadata(
* @param e - The MouseEvent object representing the click event.
* @param callback - An optional callback function that will be called with the CSLP tag and highlighted element as arguments.
*/
function highlightCslpElement(
element: HTMLElement,
cslpTag: string,
elements: ReturnType<typeof Config.get>["elements"],
callback?: (args: { cslpTag: string; highlightedElement: HTMLElement }) => void
): void {
if (elements.highlightedElement)
elements.highlightedElement.classList.remove(
cslpTagStyles()["cslp-edit-mode"]
);
element.classList.add(cslpTagStyles()["cslp-edit-mode"]);
const updatedElements = elements;
updatedElements.highlightedElement = element as DeepSignal<HTMLElement>;
Config.set("elements", updatedElements);
callback?.({ cslpTag, highlightedElement: element });
}

export function addCslpOutline(
e: MouseEvent,
callback?: (args: {
Expand All @@ -234,25 +251,26 @@ export function addCslpOutline(
const cslpTag = element.getAttribute("data-cslp");

if (trigger && isValidCslp(cslpTag)) {
if (elements.highlightedElement)
elements.highlightedElement.classList.remove(
cslpTagStyles()["cslp-edit-mode"]
);
element.classList.add(cslpTagStyles()["cslp-edit-mode"]);

const updatedElements = elements;
updatedElements.highlightedElement =
element as DeepSignal<HTMLElement>;
Config.set("elements", updatedElements);

callback?.({
cslpTag: cslpTag,
highlightedElement: element,
});

highlightCslpElement(element, cslpTag, elements, callback);
trigger = false;
} else if (!trigger) {
element.classList.remove(cslpTagStyles()["cslp-edit-mode"]);
}
}

// composedPath() misses elements that are visually under a sibling overlay;
// fall back to elementsFromPoint so the Edit button can still find the field.
if (trigger && Config.get().overlayPropagation?.enable) {
const pointElements = document.elementsFromPoint(e.clientX, e.clientY);
for (const el of pointElements) {
const element = el as HTMLElement;
if (element.nodeName === "BODY") break;
if (typeof element?.getAttribute !== "function") continue;
const cslpTag = element.getAttribute("data-cslp");
if (isValidCslp(cslpTag)) {
highlightCslpElement(element, cslpTag, elements, callback);
break;
}
}
}
}
20 changes: 20 additions & 0 deletions src/livePreview/editButton/editButton.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { throttle } from "lodash-es";
import { effect } from "@preact/signals";
import { inIframe, isOpeningInNewTab } from "../../common/inIframe";
import Config from "../../configManager/configManager";
Expand Down Expand Up @@ -281,6 +282,7 @@ export class LivePreviewEditButton {
singular: null,
multiple: null,
};
private overlayMouseMoveHandler: ((e: MouseEvent) => void) | null = null;
static livePreviewEditButton: LivePreviewEditButton | null = null;

constructor() {
Expand All @@ -297,6 +299,17 @@ export class LivePreviewEditButton {

window.addEventListener("scroll", this.updateTooltipPosition);
window.addEventListener("mouseover", this.addEditStyleOnHover);

if (Config.get().overlayPropagation?.enable) {
this.overlayMouseMoveHandler = throttle(
this.addEditStyleOnHover,
100
);
window.addEventListener(
"mousemove",
this.overlayMouseMoveHandler
);
}
}
}

Expand Down Expand Up @@ -562,6 +575,13 @@ export class LivePreviewEditButton {
destroy(): void {
window.removeEventListener("scroll", this.updateTooltipPosition);
window.removeEventListener("mouseover", this.addEditStyleOnHover);
if (this.overlayMouseMoveHandler) {
window.removeEventListener(
"mousemove",
this.overlayMouseMoveHandler
);
this.overlayMouseMoveHandler = null;
}
this.tooltip?.remove();
}
}
Expand Down
15 changes: 15 additions & 0 deletions src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export declare interface IConfig {
hash: string;
editButton: IConfigEditButton;
editInVisualBuilderButton: IConfigEditInVisualBuilderButton;
overlayPropagation: IConfigOverlayPropagation;
mode: ILivePreviewModeConfig;
elements: {
highlightedElement: HTMLElement | null;
Expand All @@ -100,6 +101,19 @@ export declare interface IConfigEditInVisualBuilderButton {
| "bottom-right"
}

export declare interface IConfigOverlayPropagation {
/**
* When `true`, Visual Builder hover/click detection falls back to
* `document.elementsFromPoint()` if the immediate `event.target` has no
* ancestor with `data-cslp`. This allows the SDK to pierce sibling
* elements (e.g. empty CSS-grid spacer cells) that visually overlap a
* `data-cslp` field and would otherwise intercept the mouse event.
*
* @default false
*/
enable: boolean;
}


export declare interface IConfigEditButton {
enable: boolean;
Expand Down Expand Up @@ -132,6 +146,7 @@ export declare interface IInitData {
stackSdk: IStackSdk;
editButton: IConfigEditButton;
editInVisualBuilderButton: IConfigEditInVisualBuilderButton;
overlayPropagation: IConfigOverlayPropagation;
mode: ILivePreviewMode;
enableLivePreviewOutsideIframe: boolean | undefined; // default: undefined
}
Expand Down
34 changes: 33 additions & 1 deletion src/visualBuilder/components/FieldToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { FieldLocationAppList } from "./FieldLocationAppList";
import { FieldLocationIcon } from "./FieldLocationIcon";
import { WorkflowStageDetails } from "../utils/getWorkflowStageDetails";
import { ResolvedVariantPermissions } from "../utils/getResolvedVariantPermissions";
import { isCustomFieldMultipleInstance as checkIsCustomFieldMultipleInstance } from "../utils/isCustomFieldMultipleInstance";

export type FieldDetails = Pick<
VisualBuilderCslpEventDetails,
Expand Down Expand Up @@ -153,6 +154,8 @@ function FieldToolbarComponent(
const APP_LIST_MIN_WIDTH = 230;

let disableFieldActions = false;
let isCustomFieldMultipleInstance = false;
let isCustomFieldWholeMultiple = false;
if (fieldSchema) {
const { isDisabled } = isFieldDisabled(
fieldSchema,
Expand Down Expand Up @@ -188,7 +191,15 @@ function FieldToolbarComponent(
fieldMetadata.instance.fieldPathWithIndex ||
fieldMetadata.multipleFieldMetadata?.index === -1);

isModalEditable = ALLOWED_MODAL_EDITABLE_FIELD.includes(fieldType) && !isWholeMultipleField;
isCustomFieldMultipleInstance = checkIsCustomFieldMultipleInstance(fieldSchema, fieldMetadata);
isCustomFieldWholeMultiple =
fieldType === FieldDataType.CUSTOM_FIELD && isMultiple && isWholeMultipleField;

if (isCustomFieldWholeMultiple) {
isModalEditable = true;
} else {
isModalEditable = ALLOWED_MODAL_EDITABLE_FIELD.includes(fieldType) && !isWholeMultipleField;
}

isReplaceAllowed =
ALLOWED_REPLACE_FIELDS.includes(fieldType) && !isWholeMultipleField;
Expand Down Expand Up @@ -425,6 +436,27 @@ function FieldToolbarComponent(
}
);

if (isCustomFieldMultipleInstance) {
return (
<div
className={classNames(
"visual-builder__field-toolbar-container",
visualBuilderStyles()["visual-builder__field-toolbar-container"]
)}
>
<div
className={classNames(
"visual-builder__custom-field-instance-message",
visualBuilderStyles()["visual-builder__custom-field-instance-message"]
)}
data-testid="visual-builder__custom-field-instance-message"
>
You're on a custom field item. Select the entire custom field to edit or manage it.
</div>
</div>
);
}

return (
<div
className={classNames(
Expand Down
Loading
Loading