diff --git a/src/visualBuilder/components/VisualBuilder.tsx b/src/visualBuilder/components/VisualBuilder.tsx
index 8cfa4fee..abdefe42 100644
--- a/src/visualBuilder/components/VisualBuilder.tsx
+++ b/src/visualBuilder/components/VisualBuilder.tsx
@@ -106,6 +106,14 @@ function VisualBuilderComponent(props: VisualBuilderProps): JSX.Element | null {
)}
data-testid="visual-builder__hover-outline"
>
+
> = {};
@@ -207,6 +211,7 @@ describe("FieldLabelWrapperComponent - Disabled Class", () => {
beforeEach(() => {
// Reset all mocks to their default state before each test
vi.clearAllMocks();
+ clearAllEntryFieldLockInfo();
// Reset isFieldDisabled to default
(isFieldDisabled as any).mockReturnValue({
@@ -279,6 +284,7 @@ describe("FieldLabelWrapperComponent - Disabled Class", () => {
afterEach(() => {
// Clean up field schema cache after each test
FieldSchemaMap.clear();
+ clearAllEntryFieldLockInfo();
// Clean up DOM after each test to prevent state pollution
document.body.innerHTML = "";
});
@@ -329,4 +335,52 @@ describe("FieldLabelWrapperComponent - Disabled Class", () => {
"visual-builder__focused-toolbar--field-disabled"
);
});
+
+ test("renders the disabled (locked) class when the field is peer-locked, even without a permission disable", async () => {
+ // isFieldDisabled stays false (no permission/workflow disable); the peer
+ // lock alone must drive the same disabled label affordance.
+ (isFieldDisabled as any).mockReturnValue({
+ isDisabled: false,
+ reason: "",
+ });
+ setEntryFieldLockInfo(
+ {
+ entryUid: mockFieldMetadata.entry_uid,
+ locale: mockFieldMetadata.locale,
+ },
+ {
+ [mockFieldMetadata.fieldPath]: {
+ user: { uid: "u1", name: "Ada Lovelace" },
+ ttl: "2030-01-01",
+ isLocked: true,
+ isOwn: false,
+ },
+ }
+ );
+
+ const { container } = render(
+
+ );
+
+ await act(async () => {
+ await new Promise((resolve) =>
+ queueMicrotask(() => resolve())
+ );
+ });
+
+ const fieldLabel = (await findByTestId(
+ container as HTMLElement,
+ "visual-builder__focused-toolbar__field-label-wrapper",
+ {},
+ { timeout: 1000 }
+ )) as HTMLElement;
+ expect(fieldLabel).toHaveClass(
+ "visual-builder__focused-toolbar--field-disabled"
+ );
+ });
});
diff --git a/src/visualBuilder/components/fieldLabelWrapper.tsx b/src/visualBuilder/components/fieldLabelWrapper.tsx
index b92a2834..c815f3be 100644
--- a/src/visualBuilder/components/fieldLabelWrapper.tsx
+++ b/src/visualBuilder/components/fieldLabelWrapper.tsx
@@ -1,5 +1,5 @@
import classNames from "classnames";
-import React, { useEffect, useRef, useState } from "preact/compat";
+import React, { useCallback, useEffect, useRef, useState } from "preact/compat";
import { extractDetailsFromCslp, isValidCslp } from "../../cslp";
import { CslpData } from "../../cslp/types/cslp.types";
import { VisualBuilderCslpEventDetails } from "../types/visualBuilder.types";
@@ -20,6 +20,8 @@ import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermiss
import { VariantIndicator } from "./VariantIndicator";
import { handleRevalidateFieldData } from "../eventManager/useRevalidateFieldDataPostMessageEvent";
import { RESULT_TYPES } from "../utils/constants";
+import { getPeerLockForField, lockAvatarInfo } from "../utils/fieldLockIndicator";
+import { subscribeEntryFieldLockInfo } from "../utils/fieldLockStore";
interface ReferenceParentMap {
[entryUid: string]: {
@@ -211,6 +213,73 @@ function FieldLabelWrapperComponent(
const [error, setError] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ // Base (non-lock) label state captured by the data fetch below. The peer
+ // lock is layered on top separately so the affordance re-renders when the
+ // lock mirror changes, without re-running the (network) fetch.
+ const labelBaseRef = useRef<{
+ text: string;
+ contentTypeName: string;
+ fieldDisabled: boolean;
+ reason: string;
+ workflowRequestUi?: "request" | "pending";
+ handleLinkVariant: () => void;
+ handleRequestEditAccess: () => void | Promise;
+ hasParentPaths: boolean;
+ isReference: boolean;
+ referenceFieldName: string;
+ parentContentTypeName: string;
+ isVariant: boolean;
+ prefixIcon: any;
+ } | null>(null);
+
+ const applyLockAffordance = useCallback(() => {
+ const base = labelBaseRef.current;
+ if (!base) return;
+ const peerLock = getPeerLockForField(props.fieldMetadata);
+ const effectiveDisabled = base.fieldDisabled || Boolean(peerLock);
+ const effectiveReason =
+ !base.fieldDisabled && peerLock
+ ? `This field is locked by ${
+ lockAvatarInfo(peerLock).name || "another user"
+ }`
+ : base.reason;
+ const usePlainDataTooltip =
+ effectiveReason &&
+ !effectiveReason.includes(DisableReason.CanLinkVariant) &&
+ base.workflowRequestUi == null;
+ setCurrentField({
+ text: base.text,
+ contentTypeName: base.contentTypeName,
+ icon: effectiveDisabled ? (
+
+ ) : base.hasParentPaths ? (
+
+ ) : (
+ <>>
+ ),
+ isReference: base.isReference,
+ prefixIcon: base.prefixIcon,
+ disabled: effectiveDisabled,
+ referenceFieldName: base.referenceFieldName,
+ parentContentTypeName: base.parentContentTypeName,
+ isVariant: base.isVariant,
+ });
+ }, [props.fieldMetadata]);
+
+ // Re-layer the lock affordance whenever the SDK lock mirror changes.
+ useEffect(
+ () => subscribeEntryFieldLockInfo(applyLockAffordance),
+ [applyLockAffordance]
+ );
+
function calculateTopOffset(index: number) {
const height = -30; // from bottom
const offset = (index + 1) * height;
@@ -351,36 +420,26 @@ function FieldLabelWrapperComponent(
const hasParentPaths = !!props?.parentPaths?.length;
const isVariant = props.fieldMetadata.variant ? true : false;
- const usePlainDataTooltip =
- reason &&
- !reason.includes(DisableReason.CanLinkVariant) &&
- workflowRequestUi == null;
-
- setCurrentField({
+ // A peer-held lock reads as disabled in the field label too (reusing
+ // the disabled info-icon + tooltip). Capture the base (non-lock)
+ // state and layer the lock on via applyLockAffordance, so a lock
+ // acquire/release re-renders the label without re-fetching.
+ labelBaseRef.current = {
text: currentFieldDisplayName,
contentTypeName: contentTypeName ?? "",
- icon: fieldDisabled ? (
-
- ) : hasParentPaths ? (
-
- ) : (
- <>>
- ),
+ fieldDisabled,
+ reason,
+ ...(workflowRequestUi != null ? { workflowRequestUi } : {}),
+ handleLinkVariant,
+ handleRequestEditAccess,
+ hasParentPaths,
isReference,
- prefixIcon: getFieldIcon(fieldSchema),
- disabled: fieldDisabled,
referenceFieldName,
parentContentTypeName,
- isVariant: isVariant,
- });
+ isVariant,
+ prefixIcon: getFieldIcon(fieldSchema),
+ };
+ applyLockAffordance();
if (displayNames) {
setDisplayNames(displayNames);
diff --git a/src/visualBuilder/eventManager/useEntryLockInfoUpdateEvent.ts b/src/visualBuilder/eventManager/useEntryLockInfoUpdateEvent.ts
new file mode 100644
index 00000000..d105f27f
--- /dev/null
+++ b/src/visualBuilder/eventManager/useEntryLockInfoUpdateEvent.ts
@@ -0,0 +1,38 @@
+import visualBuilderPostMessage from "../utils/visualBuilderPostMessage";
+import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types";
+import {
+ EntryFieldLockInfo,
+ setEntryFieldLockInfo,
+} from "../utils/fieldLockStore";
+
+interface EntryLockInfoUpdateEvent {
+ data: {
+ entryUid: string;
+ locale: string;
+ variantUid?: string;
+ fieldLockInfo: EntryFieldLockInfo;
+ };
+}
+
+/**
+ * Keeps the SDK's lock mirror live. The parent pushes an entry's current
+ * field-lock map whenever a peer focuses or blurs a field; we overwrite that
+ * entry's scope in the mirror so indicators and gating reflect it immediately.
+ * The parent stays the single source of truth — the SDK only mirrors.
+ */
+export function useEntryLockInfoUpdateEvent(): void {
+ visualBuilderPostMessage?.on(
+ VisualBuilderPostMessageEvents.ENTRY_LOCK_INFO_UPDATE,
+ (event: EntryLockInfoUpdateEvent) => {
+ const { entryUid, locale, variantUid, fieldLockInfo } = event.data;
+ setEntryFieldLockInfo(
+ {
+ entryUid,
+ locale,
+ ...(variantUid ? { variantUid } : {}),
+ },
+ fieldLockInfo ?? {}
+ );
+ }
+ );
+}
diff --git a/src/visualBuilder/generators/__test__/generateLockAvatar.test.ts b/src/visualBuilder/generators/__test__/generateLockAvatar.test.ts
new file mode 100644
index 00000000..85816f7a
--- /dev/null
+++ b/src/visualBuilder/generators/__test__/generateLockAvatar.test.ts
@@ -0,0 +1,52 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { showLockAvatar, hideLockAvatar } from "../generateLockAvatar";
+import { visualBuilderStyles } from "../../visualBuilder.style";
+
+vi.mock("../../utils/visualBuilderPostMessage", () => ({
+ default: { send: vi.fn(), on: vi.fn() },
+}));
+
+const hiddenClass = () =>
+ visualBuilderStyles()["visual-builder__lock-avatar--hidden"];
+
+const lock = {
+ user: { uid: "u1", name: "Ada Lovelace", initials: "AL", avatarColor: "#c2410c" },
+ ttl: "2030-01-01",
+ isLocked: true,
+ isOwn: false,
+};
+
+beforeEach(() => {
+ document.body.innerHTML = "";
+ const avatar = document.createElement("div");
+ avatar.className = `visual-builder__lock-avatar ${hiddenClass()}`;
+ document.body.appendChild(avatar);
+});
+
+describe("lock avatar overlay", () => {
+ it("shows the avatar with initials, colour, title, and unhides it", () => {
+ const target = document.createElement("div");
+ document.body.appendChild(target);
+
+ showLockAvatar(target, lock as never);
+
+ const avatar = document.querySelector(
+ ".visual-builder__lock-avatar"
+ )!;
+ expect(avatar.textContent).toBe("AL");
+ expect(avatar.style.backgroundColor).not.toBe("");
+ expect(avatar.getAttribute("title")).toBe("Locked by Ada Lovelace");
+ expect(avatar.classList.contains(hiddenClass())).toBe(false);
+ });
+
+ it("hides the avatar", () => {
+ const avatar = document.querySelector(
+ ".visual-builder__lock-avatar"
+ )!;
+ avatar.classList.remove(hiddenClass());
+
+ hideLockAvatar();
+
+ expect(avatar.classList.contains(hiddenClass())).toBe(true);
+ });
+});
diff --git a/src/visualBuilder/generators/generateLockAvatar.tsx b/src/visualBuilder/generators/generateLockAvatar.tsx
new file mode 100644
index 00000000..2bdc4798
--- /dev/null
+++ b/src/visualBuilder/generators/generateLockAvatar.tsx
@@ -0,0 +1,50 @@
+import { visualBuilderStyles } from "../visualBuilder.style";
+import { lockAvatarInfo } from "../utils/fieldLockIndicator";
+import type { EntryFieldLock } from "../utils/fieldLockStore";
+
+const AVATAR_SIZE = 24;
+
+/**
+ * Shows the locking author's avatar at the hovered field's top-left corner,
+ * sitting just outside the field border. Reuses the single overlay avatar node
+ * (rendered by VisualBuilder), so it follows the hover the same way the hover
+ * outline does. Only used on hover of a peer-locked field.
+ */
+export function showLockAvatar(
+ targetElement: Element,
+ lock: EntryFieldLock
+): void {
+ const avatar = document.querySelector(
+ ".visual-builder__lock-avatar"
+ );
+ if (!avatar) {
+ return;
+ }
+ const rect = targetElement.getBoundingClientRect();
+ const { initials, color, name } = lockAvatarInfo(lock);
+
+ avatar.textContent = initials;
+ avatar.style.backgroundColor = color;
+ avatar.setAttribute(
+ "title",
+ name ? `Locked by ${name}` : "Locked by another user"
+ );
+ // Centre the badge on the field's top-left corner so it sits outside the border.
+ avatar.style.top = `${rect.top + window.scrollY - AVATAR_SIZE / 2}px`;
+ avatar.style.left = `${rect.left + window.scrollX - AVATAR_SIZE / 2}px`;
+ avatar.classList.remove(
+ visualBuilderStyles()["visual-builder__lock-avatar--hidden"]
+ );
+}
+
+export function hideLockAvatar(): void {
+ const avatar = document.querySelector(
+ ".visual-builder__lock-avatar"
+ );
+ if (!avatar) {
+ return;
+ }
+ avatar.classList.add(
+ visualBuilderStyles()["visual-builder__lock-avatar--hidden"]
+ );
+}
diff --git a/src/visualBuilder/index.ts b/src/visualBuilder/index.ts
index f484f1a2..37a082eb 100644
--- a/src/visualBuilder/index.ts
+++ b/src/visualBuilder/index.ts
@@ -25,6 +25,7 @@ import { h } from "preact";
import { extractDetailsFromCslp, isValidCslp } from "../cslp";
import initUI from "./components";
import { useDraftFieldsPostMessageEvent } from "./eventManager/useDraftFieldsPostMessageEvent";
+import { useEntryLockInfoUpdateEvent } from "./eventManager/useEntryLockInfoUpdateEvent";
import { useHideFocusOverlayPostMessageEvent } from "./eventManager/useHideFocusOverlayPostMessageEvent";
import { useScrollToField } from "./eventManager/useScrollToField";
import { debounceAddVariantFieldClass, getHighlightVariantFieldsStatus, setHighlightVariantFields, useVariantFieldsPostMessageEvent } from "./eventManager/useVariantsPostMessageEvent";
@@ -414,6 +415,7 @@ export class VisualBuilder {
useOnEntryUpdatePostMessageEvent();
useRecalculateVariantDataCSLPValues();
useDraftFieldsPostMessageEvent();
+ useEntryLockInfoUpdateEvent();
useVariantFieldsPostMessageEvent({ isSSR: config.ssr ?? false });
}
})
diff --git a/src/visualBuilder/listeners/__test__/mouseClick.test.ts b/src/visualBuilder/listeners/__test__/mouseClick.test.ts
index cb41cff9..4ee536f3 100644
--- a/src/visualBuilder/listeners/__test__/mouseClick.test.ts
+++ b/src/visualBuilder/listeners/__test__/mouseClick.test.ts
@@ -13,8 +13,22 @@ vi.mock("../../utils/getCsDataOfElement", () => ({
getDOMEditStack: vi.fn().mockReturnValue([]),
}));
+const { mockIsValidCslp } = vi.hoisted(() => ({
+ mockIsValidCslp: vi.fn().mockReturnValue(false),
+}));
+
vi.mock("../../cslp", () => ({
- isValidCslp: vi.fn().mockReturnValue(false),
+ isValidCslp: mockIsValidCslp,
+}));
+
+const { mockExtractDetailsFromCslp } = vi.hoisted(() => ({
+ mockExtractDetailsFromCslp: vi
+ .fn()
+ .mockReturnValue({ entry_uid: "entry1", fieldPathWithIndex: "field.0" }),
+}));
+
+vi.mock("../../cslp/cslpdata", () => ({
+ extractDetailsFromCslp: mockExtractDetailsFromCslp,
}));
vi.mock("../../generators/generateToolbar", () => ({
@@ -27,8 +41,12 @@ vi.mock("../../generators/generateOverlay", () => ({
hideOverlay: vi.fn(),
}));
+const { mockPostMessageSend } = vi.hoisted(() => ({
+ mockPostMessageSend: vi.fn().mockResolvedValue(undefined),
+}));
+
vi.mock("../../utils/visualBuilderPostMessage", () => ({
- default: { send: vi.fn().mockResolvedValue(undefined) },
+ default: { send: mockPostMessageSend },
}));
vi.mock("../../utils/types/postMessage.types", () => ({
@@ -369,3 +387,92 @@ describe("handleBuilderInteraction — alt+click on anchor", () => {
expect(window.location.href).toBe(anchor.href);
});
});
+
+describe("handleBuilderInteraction — inline editor click keeps the field lock", () => {
+ let focusedField: HTMLElement;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ document.body.innerHTML = "";
+ hoistedConfigMocks.configGet.mockReturnValue({
+ collab: { enable: false, isFeedbackMode: false, pauseFeedback: false },
+ });
+ focusedField = document.createElement("div");
+ focusedField.setAttribute("data-cslp", "ct.entry1.en-us.field.0");
+ document.body.appendChild(focusedField);
+ VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM =
+ focusedField;
+ VisualBuilder.VisualBuilderGlobalState.value.isFocussed = true;
+ // getCsDataOfElement finds nothing (pseudo-editable and empty space both
+ // resolve to no data-cslp)
+ vi.mocked(getCsDataOfElement).mockReturnValue(undefined as any);
+ mockIsValidCslp.mockReturnValue(true);
+ });
+
+ it("carries the focused field's metadata on MOUSE_CLICK when the click is inside the pseudo-editable overlay", async () => {
+ const pseudo = document.createElement("div");
+ pseudo.className = "visual-builder__pseudo-editable-element";
+ document.body.appendChild(pseudo);
+
+ await handleBuilderInteraction(makeParams(pseudo));
+
+ expect(mockPostMessageSend).toHaveBeenCalledWith(
+ "MOUSE_CLICK",
+ expect.objectContaining({
+ cslpData: "ct.entry1.en-us.field.0",
+ fieldMetadata: expect.anything(),
+ }),
+ );
+ });
+
+ it("carries the focused field's metadata when the click is inside the focused field itself (in-place editor reposition)", async () => {
+ // A no-cslp click on a child of the focused field (e.g. repositioning the
+ // cursor inside an RTE / in-place editor) is not a deselect.
+ const innerNode = document.createElement("span");
+ focusedField.appendChild(innerNode);
+
+ await handleBuilderInteraction(makeParams(innerNode));
+
+ expect(mockPostMessageSend).toHaveBeenCalledWith(
+ "MOUSE_CLICK",
+ expect.objectContaining({
+ cslpData: "ct.entry1.en-us.field.0",
+ fieldMetadata: expect.anything(),
+ }),
+ );
+ });
+
+ it("carries the focused field's metadata when clicking the field toolbar chrome (e.g. Replace)", async () => {
+ // The file/asset Replace button lives in the SDK field toolbar, not the
+ // content DOM, so it has no data-cslp — but clicking it acts on the focused
+ // field and must not release the lock.
+ const toolbar = document.createElement("div");
+ toolbar.className = "visual-builder__field-toolbar-container";
+ const replaceBtn = document.createElement("button");
+ replaceBtn.setAttribute("data-testid", "visual-builder-replace-file");
+ toolbar.appendChild(replaceBtn);
+ document.body.appendChild(toolbar);
+
+ await handleBuilderInteraction(makeParams(replaceBtn));
+
+ expect(mockPostMessageSend).toHaveBeenCalledWith(
+ "MOUSE_CLICK",
+ expect.objectContaining({
+ cslpData: "ct.entry1.en-us.field.0",
+ fieldMetadata: expect.anything(),
+ }),
+ );
+ });
+
+ it("sends no fieldMetadata on a genuine empty-space click (a real deselect)", async () => {
+ const emptyTarget = document.createElement("div");
+ document.body.appendChild(emptyTarget);
+
+ await handleBuilderInteraction(makeParams(emptyTarget));
+
+ expect(mockPostMessageSend).toHaveBeenCalledWith("MOUSE_CLICK", {
+ cslpData: undefined,
+ fieldMetadata: undefined,
+ });
+ });
+});
diff --git a/src/visualBuilder/listeners/__test__/mouseHover.test.ts b/src/visualBuilder/listeners/__test__/mouseHover.test.ts
index 9f2ac67d..17253789 100644
--- a/src/visualBuilder/listeners/__test__/mouseHover.test.ts
+++ b/src/visualBuilder/listeners/__test__/mouseHover.test.ts
@@ -3,6 +3,10 @@ import handleMouseHover from "../mouseHover";
import { VisualBuilder } from "../../index";
import * as fetchEntryPermissionsModule from "../../utils/fetchEntryPermissionsAndStageDetails";
import { FieldSchemaMap } from "../../utils/fieldSchemaMap";
+import {
+ setEntryFieldLockInfo,
+ clearAllEntryFieldLockInfo,
+} from "../../utils/fieldLockStore";
vi.mock("lodash-es", async () => ({
...(await import("lodash-es")),
@@ -141,6 +145,7 @@ describe("mouseHover — custom field multiple instance suppression", () => {
VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = null;
VisualBuilder.VisualBuilderGlobalState.value.isFocussed = false;
vi.mocked(FieldSchemaMap.hasFieldSchema).mockReturnValue(true);
+ clearAllEntryFieldLockInfo();
});
it("dispatches mousemove on whole-field element for custom field multiple instance", async () => {
@@ -184,6 +189,30 @@ describe("mouseHover — custom field multiple instance suppression", () => {
expect(addHoverOutline).toHaveBeenCalled();
});
+ it("grays the hover cursor for a peer-locked field (same disabled affordance as a disabled field)", async () => {
+ setEntryFieldLockInfo(
+ { entryUid: "entry1", locale: "en-us" },
+ {
+ title: {
+ user: { uid: "u1", name: "Ada", initials: "AD" },
+ ttl: "2030-01-01",
+ isLocked: true,
+ isOwn: false,
+ },
+ },
+ );
+ vi.mocked(isCustomFieldMultipleInstance).mockReturnValue(false);
+ mockedGetCsDataOfElement.mockReturnValue(
+ makeEventDetails(editableElement) as any,
+ );
+
+ await handleMouseHover(makeParams(editableElement, customCursor));
+
+ expect(vi.mocked(generateCustomCursor)).toHaveBeenCalledWith(
+ expect.objectContaining({ fieldDisabled: true }),
+ );
+ });
+
it("does not suppress when schema is not yet cached (hasFieldSchema returns false)", async () => {
vi.mocked(FieldSchemaMap.hasFieldSchema).mockReturnValue(false);
vi.mocked(isCustomFieldMultipleInstance).mockReturnValue(true);
diff --git a/src/visualBuilder/listeners/mouseClick.ts b/src/visualBuilder/listeners/mouseClick.ts
index b874a198..0e9a0ef0 100644
--- a/src/visualBuilder/listeners/mouseClick.ts
+++ b/src/visualBuilder/listeners/mouseClick.ts
@@ -8,6 +8,7 @@ import {
getDOMEditStack,
} from "../utils/getCsDataOfElement";
import { isValidCslp } from "../../cslp";
+import { extractDetailsFromCslp } from "../../cslp/cslpdata";
import { appendFocusedToolbar } from "../generators/generateToolbar";
@@ -20,6 +21,7 @@ import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types
import { VisualBuilder } from "..";
import { FieldSchemaMap } from "../utils/fieldSchemaMap";
import { isFieldDisabled } from "../utils/isFieldDisabled";
+import { getPeerLockForField } from "../utils/fieldLockIndicator";
import EventListenerHandlerParams from "./types";
import { toggleHighlightedCommentIconDisplay } from "../generators/generateHighlightedComment";
import { VB_EmptyBlockParentClass } from "../..";
@@ -180,8 +182,25 @@ export async function handleBuilderInteraction(
const eventDetails = getCsDataOfElement(params.event);
- // Send mouse click post message
- sendMouseClickPostMessage(eventDetails);
+ // A field locked by another user is not editable — block entering edit mode
+ // (the hover state already shows it disabled with the author avatar). This
+ // gate runs before the post message so a click on a peer-locked field is a
+ // true no-op: posting MOUSE_CLICK with its fieldMetadata makes the host read
+ // it as a fresh selection and cancel the current user's own pending lock
+ // release.
+ if (eventDetails && getPeerLockForField(eventDetails.fieldMetadata)) {
+ return;
+ }
+
+ // Send mouse click post message. A click inside the active inline editor (the
+ // pseudo-editable overlay) resolves to no data-cslp — the overlay lives in the
+ // SDK container, not the content DOM — but it is NOT a deselect: the user is
+ // just repositioning the cursor within the focused field. Carry the focused
+ // field's metadata so the host keeps the field lock instead of reading an
+ // empty-space click as a deselect and releasing it.
+ sendMouseClickPostMessage(
+ eventDetails ?? getInlineEditFieldDetails(eventTarget)
+ );
if (
!eventDetails ||
@@ -278,6 +297,45 @@ function sendMouseClickPostMessage(eventDetails: any) {
console.warn("Error while sending post message", err);
});
}
+
+/** SDK-owned chrome for the focused field (its toolbar + action buttons like
+ * Replace / Edit / move / revert). These live in the SDK container, not the
+ * content DOM, so they carry no data-cslp — but clicking them acts ON the focused
+ * field, so it must not be read as a deselect. */
+const FOCUSED_FIELD_CHROME_SELECTOR =
+ ".visual-builder__pseudo-editable-element," +
+ ".visual-builder__focused-toolbar," +
+ ".visual-builder__field-toolbar-container";
+
+/**
+ * When a no-cslp click is still an interaction with the currently focused field —
+ * inside its inline-edit overlay, inside the field element itself (cursor
+ * reposition / text selection), or on its SDK toolbar chrome (Replace, Edit,
+ * move, revert, field-path dropdown) — resolve the focused field's cslp so
+ * MOUSE_CLICK still carries fieldMetadata and the host keeps the lock. Returns
+ * undefined for any other empty click (a genuine deselect).
+ */
+function getInlineEditFieldDetails(
+ eventTarget: HTMLElement | null
+): { cslpData: string; fieldMetadata: CslpData } | undefined {
+ const focusedElement =
+ VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM;
+ if (!focusedElement) {
+ return undefined;
+ }
+ const insideFieldChrome = !!eventTarget?.closest?.(
+ FOCUSED_FIELD_CHROME_SELECTOR
+ );
+ const insideFocusedField = !!eventTarget && focusedElement.contains(eventTarget);
+ if (!insideFieldChrome && !insideFocusedField) {
+ return undefined;
+ }
+ const cslpData = focusedElement.getAttribute?.("data-cslp") ?? null;
+ if (!isValidCslp(cslpData)) {
+ return undefined;
+ }
+ return { cslpData, fieldMetadata: extractDetailsFromCslp(cslpData) };
+}
function cleanResidualsIfNeeded(
params: HandleBuilderInteractionParams,
editableElement: Element
diff --git a/src/visualBuilder/listeners/mouseHover.ts b/src/visualBuilder/listeners/mouseHover.ts
index b0d20363..54916265 100644
--- a/src/visualBuilder/listeners/mouseHover.ts
+++ b/src/visualBuilder/listeners/mouseHover.ts
@@ -20,6 +20,12 @@ import { CslpData } from "../../cslp/types/cslp.types";
import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermissionsAndStageDetails";
import { isCustomFieldMultipleInstance } from "../utils/isCustomFieldMultipleInstance";
import { getParentCslp, getWholeFieldElement } from "../utils/getWholeFieldElement";
+import { getPeerLockForField } from "../utils/fieldLockIndicator";
+import { subscribeEntryFieldLockInfo } from "../utils/fieldLockStore";
+import {
+ showLockAvatar,
+ hideLockAvatar,
+} from "../generators/generateLockAvatar";
const config = Config.get();
export interface HandleMouseHoverParams
@@ -84,7 +90,22 @@ async function addOutline(params?: AddOutlineParams): Promise {
} = params;
if (!editableElement) return;
const isVariant = !!fieldMetadata.variant;
- addHoverOutline(editableElement as HTMLElement, fieldDisabled, isVariant);
+ // Peer-lock is a hover-only affordance: if this field is locked by another
+ // user, show the disabled hover state + the author avatar (top-left, outside
+ // the border). Read the mirror synchronously for an instant paint on cached
+ // entries; it is re-read after the async fetch below in case the snapshot
+ // only just arrived for a first-time-hovered entry.
+ let peerLock = getPeerLockForField(fieldMetadata);
+ if (peerLock) {
+ showLockAvatar(editableElement, peerLock);
+ } else {
+ hideLockAvatar();
+ }
+ addHoverOutline(
+ editableElement as HTMLElement,
+ fieldDisabled || Boolean(peerLock),
+ isVariant
+ );
const fieldSchema = await FieldSchemaMap.getFieldSchema(
content_type_uid,
fieldPath
@@ -105,12 +126,55 @@ async function addOutline(params?: AddOutlineParams): Promise {
entryAcl,
entryWorkflowStageDetails
);
- addHoverOutline(editableElement, fieldDisabled || isDisabled, isVariant);
+ peerLock = getPeerLockForField(fieldMetadata);
+ if (peerLock) {
+ showLockAvatar(editableElement, peerLock);
+ } else {
+ hideLockAvatar();
+ }
+ addHoverOutline(
+ editableElement,
+ fieldDisabled || isDisabled || Boolean(peerLock),
+ isVariant
+ );
}
const debouncedAddOutline = debounce(addOutline, 50, { trailing: true });
export const cancelPendingAddOutline = () => debouncedAddOutline.cancel();
-const showOutline = (params?: AddOutlineParams): Promise | undefined => debouncedAddOutline(params);
+// Remember the last painted hover so a lock change can repaint the same element
+// (see the subscription below) without waiting for the next mouse move.
+let lastAddOutlineParams: AddOutlineParams | undefined;
+const showOutline = (params?: AddOutlineParams): Promise | undefined => {
+ if (params) {
+ lastAddOutlineParams = params;
+ }
+ return debouncedAddOutline(params);
+};
+
+// Repaint the hover affordance when the lock mirror changes, so a lock acquired
+// or released while the pointer sits on a field updates immediately. Guarded to
+// the element still under the pointer with a visible outline, so a stale paint
+// never resurrects a hidden outline.
+subscribeEntryFieldLockInfo(() => {
+ const params = lastAddOutlineParams;
+ if (!params) return;
+ if (
+ VisualBuilder.VisualBuilderGlobalState.value.previousHoveredTargetDOM !==
+ params.editableElement
+ ) {
+ return;
+ }
+ const outline = document.querySelector(".visual-builder__hover-outline");
+ if (
+ !outline ||
+ outline.classList.contains(
+ visualBuilderStyles()["visual-builder__hover-outline--hidden"]
+ )
+ ) {
+ return;
+ }
+ void addOutline(params);
+});
function hideDefaultCursor(): void {
if (
@@ -151,6 +215,7 @@ export function hideHoverOutline(
hoverOutline.classList.add(
visualBuilderStyles()["visual-builder__hover-outline--hidden"]
);
+ hideLockAvatar();
}
export function hideCustomCursor(customCursor: HTMLDivElement | null): void {
@@ -246,6 +311,9 @@ const throttledMouseHover = throttle(async (params: HandleMouseHoverParams) => {
}
if (!config?.collab.enable) {
resetCustomCursor(params.customCursor);
+ // Empty space: drop any peer-lock avatar left over from a prior
+ // field hover so it does not ghost over the blank canvas.
+ hideLockAvatar();
}
removeAddInstanceButtons({
eventTarget: params.event.target,
@@ -418,10 +486,12 @@ async function generateCursor({
entryWorkflowStageDetails
);
const fieldType = getFieldType(fieldSchema);
+ // A peer-locked field reads as disabled on hover (same grey outline), so gray
+ // the cursor info too for a consistent disabled affordance.
generateCustomCursor({
fieldType,
customCursor,
- fieldDisabled,
+ fieldDisabled: fieldDisabled || Boolean(getPeerLockForField(fieldMetadata)),
});
}
diff --git a/src/visualBuilder/utils/__test__/fieldLockIndicator.test.ts b/src/visualBuilder/utils/__test__/fieldLockIndicator.test.ts
new file mode 100644
index 00000000..a8337e92
--- /dev/null
+++ b/src/visualBuilder/utils/__test__/fieldLockIndicator.test.ts
@@ -0,0 +1,153 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { getPeerLockForField, lockAvatarInfo } from "../fieldLockIndicator";
+import {
+ setEntryFieldLockInfo,
+ clearAllEntryFieldLockInfo,
+} from "../fieldLockStore";
+
+vi.mock("../visualBuilderPostMessage", () => ({
+ default: { send: vi.fn(), on: vi.fn() },
+}));
+
+const scope = { entryUid: "entry1", contentTypeUid: "page", locale: "en-us" };
+
+const meta = (over: Record = {}) => ({
+ entry_uid: "entry1",
+ locale: "en-us",
+ variant: undefined,
+ fieldPath: "title",
+ fieldPathWithIndex: "title",
+ ...over,
+});
+
+const peer = (over: Record = {}) => ({
+ user: { uid: "u1", name: "Ada Lovelace", initials: "AL", avatarColor: "#c2410c" },
+ ttl: "2030-01-01",
+ isLocked: true,
+ isOwn: false,
+ ...over,
+});
+
+beforeEach(() => {
+ clearAllEntryFieldLockInfo();
+});
+
+describe("getPeerLockForField", () => {
+ it("returns the peer lock for a field locked by another user", () => {
+ setEntryFieldLockInfo(scope, { title: peer() });
+ expect(getPeerLockForField(meta() as never)?.user.uid).toBe("u1");
+ });
+
+ it("returns null for the current user's own lock", () => {
+ setEntryFieldLockInfo(scope, { title: peer({ isOwn: true }) });
+ expect(getPeerLockForField(meta() as never)).toBeNull();
+ });
+
+ it("returns null for an unlocked field", () => {
+ expect(getPeerLockForField(meta() as never)).toBeNull();
+ });
+
+ it("treats a lock with no isOwn flag as a peer lock", () => {
+ setEntryFieldLockInfo(scope, {
+ title: { user: { uid: "u1" }, ttl: "2030-01-01", isLocked: true },
+ });
+ expect(getPeerLockForField(meta() as never)).not.toBeNull();
+ });
+
+ it("matches by fieldPathWithIndex, falling back to fieldPath", () => {
+ setEntryFieldLockInfo(scope, {
+ "page_components.0.section.title_h3": peer(),
+ });
+ const found = getPeerLockForField(
+ meta({
+ fieldPath: "page_components.section.title_h3",
+ fieldPathWithIndex: "page_components.0.section.title_h3",
+ }) as never
+ );
+ expect(found).not.toBeNull();
+ });
+
+ it("scopes the lookup by variant", () => {
+ setEntryFieldLockInfo({ ...scope, variantUid: "var1" }, { title: peer() });
+ expect(getPeerLockForField(meta({ variant: "var1" }) as never)).not.toBeNull();
+ // base scope has no lock
+ expect(getPeerLockForField(meta() as never)).toBeNull();
+ });
+
+ it("locks a child when an ancestor container is peer-locked", () => {
+ setEntryFieldLockInfo(scope, { "section.0": peer() });
+ const found = getPeerLockForField(
+ meta({
+ fieldPath: "section.title",
+ fieldPathWithIndex: "section.0.title",
+ }) as never
+ );
+ expect(found?.user.uid).toBe("u1");
+ });
+
+ it("locks a container when a descendant field is peer-locked", () => {
+ setEntryFieldLockInfo(scope, { "section.0.title": peer() });
+ const found = getPeerLockForField(
+ meta({
+ fieldPath: "section",
+ fieldPathWithIndex: "section.0",
+ }) as never
+ );
+ expect(found?.user.uid).toBe("u1");
+ });
+
+ it("does not lock a sibling that only shares a path prefix", () => {
+ setEntryFieldLockInfo(scope, { "section.0.title": peer() });
+ expect(
+ getPeerLockForField(
+ meta({
+ fieldPath: "section.subtitle",
+ fieldPathWithIndex: "section.0.subtitle",
+ }) as never
+ )
+ ).toBeNull();
+ });
+
+ it("does not propagate the current user's own container lock", () => {
+ setEntryFieldLockInfo(scope, { "section.0": peer({ isOwn: true }) });
+ expect(
+ getPeerLockForField(
+ meta({
+ fieldPath: "section.title",
+ fieldPathWithIndex: "section.0.title",
+ }) as never
+ )
+ ).toBeNull();
+ });
+});
+
+describe("lockAvatarInfo", () => {
+ it("uses initials, color, and name from the lock", () => {
+ expect(lockAvatarInfo(peer() as never)).toEqual({
+ initials: "AL",
+ color: "#c2410c",
+ name: "Ada Lovelace",
+ });
+ });
+
+ it("derives initials from the name when initials are absent", () => {
+ expect(
+ lockAvatarInfo({
+ user: { uid: "u", name: "grace hopper" },
+ ttl: "x",
+ isLocked: true,
+ } as never).initials
+ ).toBe("GR");
+ });
+
+ it("falls back to '?' and the default color when nothing resolves", () => {
+ const info = lockAvatarInfo({
+ user: { uid: "u" },
+ ttl: "x",
+ isLocked: true,
+ } as never);
+ expect(info.initials).toBe("?");
+ expect(info.color).toBe("#6c5ce7");
+ expect(info.name).toBe("");
+ });
+});
diff --git a/src/visualBuilder/utils/__test__/getEntryLockInfo.test.ts b/src/visualBuilder/utils/__test__/getEntryLockInfo.test.ts
new file mode 100644
index 00000000..1bd10028
--- /dev/null
+++ b/src/visualBuilder/utils/__test__/getEntryLockInfo.test.ts
@@ -0,0 +1,240 @@
+import {
+ describe,
+ it,
+ expect,
+ vi,
+ beforeEach,
+ afterEach,
+ MockedObject,
+} from "vitest";
+import { EventManager } from "@contentstack/advanced-post-message";
+import { getEntryLockInfo } from "../getEntryLockInfo";
+import { useEntryLockInfoUpdateEvent } from "../../eventManager/useEntryLockInfoUpdateEvent";
+import { VisualBuilderPostMessageEvents } from "../types/postMessage.types";
+import visualBuilderPostMessage from "../visualBuilderPostMessage";
+import {
+ getEntryFieldLockInfo,
+ setEntryFieldLockInfo,
+ clearAllEntryFieldLockInfo,
+ subscribeEntryFieldLockInfo,
+ getEntryFieldLockVersion,
+ entryLockScopeKey,
+ EntryLockScope,
+ EntryFieldLockInfo,
+} from "../fieldLockStore";
+
+vi.mock("../visualBuilderPostMessage", () => ({
+ default: { send: vi.fn(), on: vi.fn() },
+}));
+
+const mockPostMessage = visualBuilderPostMessage as MockedObject;
+
+const scope: EntryLockScope = {
+ entryUid: "entry_1",
+ contentTypeUid: "ct_1",
+ locale: "en-us",
+};
+
+const locks: EntryFieldLockInfo = {
+ title: {
+ user: { uid: "user_a" },
+ ttl: "2030-01-01T00:00:00.000Z",
+ isLocked: true,
+ },
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ clearAllEntryFieldLockInfo();
+ vi.spyOn(console, "debug").mockImplementation(() => {});
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("getEntryLockInfo", () => {
+ it("requests the lock snapshot for the entry and returns it", async () => {
+ mockPostMessage.send.mockResolvedValue({ fieldLockInfo: locks });
+
+ const result = await getEntryLockInfo(scope);
+
+ expect(mockPostMessage.send).toHaveBeenCalledWith(
+ VisualBuilderPostMessageEvents.GET_ENTRY_LOCK_INFO,
+ { entryUid: "entry_1", contentTypeUid: "ct_1", locale: "en-us" }
+ );
+ expect(result).toEqual(locks);
+ });
+
+ it("stores the snapshot in the SDK mirror", async () => {
+ mockPostMessage.send.mockResolvedValue({ fieldLockInfo: locks });
+
+ await getEntryLockInfo(scope);
+
+ expect(getEntryFieldLockInfo(scope)).toEqual(locks);
+ });
+
+ it("returns null (not an empty map) and does not throw when the request fails", async () => {
+ mockPostMessage.send.mockRejectedValue(new Error("bridge down"));
+
+ // null lets the caller distinguish a transient failure from a genuine
+ // "no locks" snapshot and retry, instead of caching the failure.
+ await expect(getEntryLockInfo(scope)).resolves.toBeNull();
+ });
+
+ it("returns an empty map for a genuine no-locks snapshot", async () => {
+ mockPostMessage.send.mockResolvedValue({ fieldLockInfo: {} });
+
+ await expect(getEntryLockInfo(scope)).resolves.toEqual({});
+ expect(getEntryFieldLockInfo(scope)).toEqual({});
+ });
+
+ it("returns null and does not cache when the parent reports a failure", async () => {
+ mockPostMessage.send.mockResolvedValue({ error: true });
+
+ // The parent flags a fetch failure with `error: true` so the SDK retries
+ // rather than caching an empty snapshot for the session.
+ await expect(getEntryLockInfo(scope)).resolves.toBeNull();
+ expect(getEntryFieldLockInfo(scope)).toEqual({});
+ });
+
+ it("returns null for a malformed/empty response", async () => {
+ mockPostMessage.send.mockResolvedValue(undefined);
+
+ await expect(getEntryLockInfo(scope)).resolves.toBeNull();
+ });
+
+ it("does not overwrite a newer delta that lands during the request", async () => {
+ let resolveSnapshot: (v: { fieldLockInfo: EntryFieldLockInfo }) => void =
+ () => {};
+ mockPostMessage.send.mockImplementation(
+ () =>
+ new Promise((res) => {
+ resolveSnapshot = res as (v: {
+ fieldLockInfo: EntryFieldLockInfo;
+ }) => void;
+ })
+ );
+
+ const pending = getEntryLockInfo(scope);
+ // A delta clears the scope while the snapshot is still in flight.
+ setEntryFieldLockInfo(scope, {});
+ // The now-stale snapshot (title locked) resolves and must not re-lock it.
+ resolveSnapshot({ fieldLockInfo: locks });
+ await pending;
+
+ expect(getEntryFieldLockInfo(scope)).toEqual({});
+ });
+
+ it("passes the variant through for variant entries", async () => {
+ mockPostMessage.send.mockResolvedValue({ fieldLockInfo: {} });
+
+ await getEntryLockInfo({ ...scope, variantUid: "variant_1" });
+
+ expect(mockPostMessage.send).toHaveBeenCalledWith(
+ VisualBuilderPostMessageEvents.GET_ENTRY_LOCK_INFO,
+ {
+ entryUid: "entry_1",
+ contentTypeUid: "ct_1",
+ locale: "en-us",
+ variantUid: "variant_1",
+ }
+ );
+ });
+});
+
+describe("fieldLockStore", () => {
+ beforeEach(() => clearAllEntryFieldLockInfo());
+
+ it("keys by entry, locale, and variant (content type excluded, matching the parent + Redis channel)", () => {
+ expect(entryLockScopeKey(scope)).toBe("entry_1.en-us");
+ expect(entryLockScopeKey({ ...scope, variantUid: "variant_1" })).toBe(
+ "entry_1.en-us.variant_1"
+ );
+ });
+
+ it("round-trips and keeps base and variant scopes separate", () => {
+ setEntryFieldLockInfo(scope, locks);
+ setEntryFieldLockInfo({ ...scope, variantUid: "variant_1" }, {});
+
+ expect(getEntryFieldLockInfo(scope)).toEqual(locks);
+ expect(
+ getEntryFieldLockInfo({ ...scope, variantUid: "variant_1" })
+ ).toEqual({});
+ });
+
+ it("returns an empty map for an unknown scope", () => {
+ expect(
+ getEntryFieldLockInfo({
+ entryUid: "missing",
+ contentTypeUid: "ct_1",
+ locale: "en-us",
+ })
+ ).toEqual({});
+ });
+
+ it("notifies subscribers on write and clear; unsubscribe stops them", () => {
+ const listener = vi.fn();
+ const unsubscribe = subscribeEntryFieldLockInfo(listener);
+
+ setEntryFieldLockInfo(scope, locks);
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ clearAllEntryFieldLockInfo();
+ expect(listener).toHaveBeenCalledTimes(2);
+
+ unsubscribe();
+ setEntryFieldLockInfo(scope, {});
+ expect(listener).toHaveBeenCalledTimes(2);
+ });
+
+ it("bumps the scope version on each write", () => {
+ const before = getEntryFieldLockVersion(scope);
+ setEntryFieldLockInfo(scope, locks);
+ expect(getEntryFieldLockVersion(scope)).toBeGreaterThan(before);
+ });
+});
+
+describe("useEntryLockInfoUpdateEvent", () => {
+ const handlers: Record void> = {};
+
+ beforeEach(() => {
+ clearAllEntryFieldLockInfo();
+ for (const key of Object.keys(handlers)) delete handlers[key];
+ (mockPostMessage.on as unknown as ReturnType).mockImplementation(
+ (event: string, cb: (e: { data: unknown }) => void) => {
+ handlers[event] = cb;
+ }
+ );
+ });
+
+ it("registers a handler for the ENTRY_LOCK_INFO_UPDATE push", () => {
+ useEntryLockInfoUpdateEvent();
+
+ expect(mockPostMessage.on).toHaveBeenCalledWith(
+ VisualBuilderPostMessageEvents.ENTRY_LOCK_INFO_UPDATE,
+ expect.any(Function)
+ );
+ });
+
+ it("writes a pushed lock map into the mirror for that entry scope", () => {
+ useEntryLockInfoUpdateEvent();
+
+ handlers[VisualBuilderPostMessageEvents.ENTRY_LOCK_INFO_UPDATE]({
+ data: { entryUid: "entry_1", locale: "en-us", fieldLockInfo: locks },
+ });
+
+ expect(getEntryFieldLockInfo(scope)).toEqual(locks);
+ });
+
+ it("replaces the scope on each push so released locks drop out", () => {
+ setEntryFieldLockInfo(scope, locks);
+ useEntryLockInfoUpdateEvent();
+
+ handlers[VisualBuilderPostMessageEvents.ENTRY_LOCK_INFO_UPDATE]({
+ data: { entryUid: "entry_1", locale: "en-us", fieldLockInfo: {} },
+ });
+
+ expect(getEntryFieldLockInfo(scope)).toEqual({});
+ });
+});
diff --git a/src/visualBuilder/utils/fetchEntryPermissionsAndStageDetails.ts b/src/visualBuilder/utils/fetchEntryPermissionsAndStageDetails.ts
index 6fe2b305..a1ec0259 100644
--- a/src/visualBuilder/utils/fetchEntryPermissionsAndStageDetails.ts
+++ b/src/visualBuilder/utils/fetchEntryPermissionsAndStageDetails.ts
@@ -1,6 +1,7 @@
import { getEntryPermissionsCached } from "./getEntryPermissionsCached";
import { getResolvedVariantPermissions } from "./getResolvedVariantPermissions";
import { getWorkflowStageDetails } from "./getWorkflowStageDetails";
+import { requestEntryLockInfoOnce } from "./fieldLockIndicator";
export async function fetchEntryPermissionsAndStageDetails({
entryUid,
@@ -15,6 +16,16 @@ export async function fetchEntryPermissionsAndStageDetails({
fieldPathWithIndex: string;
variantUid?: string | undefined;
}) {
+ // Fire-and-forget: pull this entry's lock snapshot once and paint indicators.
+ // De-duped per entry (the parent pushes deltas afterwards), so hovering does
+ // not spam requests, and it must not block the permission/stage fetch below.
+ void requestEntryLockInfoOnce({
+ entryUid,
+ contentTypeUid,
+ locale,
+ ...(variantUid ? { variantUid } : {}),
+ });
+
const entryAclPromise = getEntryPermissionsCached({
entryUid,
contentTypeUid,
diff --git a/src/visualBuilder/utils/fieldLockIndicator.ts b/src/visualBuilder/utils/fieldLockIndicator.ts
new file mode 100644
index 00000000..b648e88f
--- /dev/null
+++ b/src/visualBuilder/utils/fieldLockIndicator.ts
@@ -0,0 +1,105 @@
+import { getEntryFieldLockInfo } from "./fieldLockStore";
+import { getEntryLockInfo } from "./getEntryLockInfo";
+import type { EntryFieldLock, EntryLockScope } from "./fieldLockStore";
+import type { CslpData } from "../../cslp/types/cslp.types";
+
+type FieldMetadataForLock = Pick<
+ CslpData,
+ "entry_uid" | "locale" | "variant" | "fieldPath" | "fieldPathWithIndex"
+>;
+
+/** A lock counts as a peer lock when it is locked and not the current user's. */
+function asPeerLock(lock: EntryFieldLock | undefined): EntryFieldLock | null {
+ return lock?.isLocked && lock.isOwn !== true ? lock : null;
+}
+
+/**
+ * Returns the peer lock (a lock held by another user) for a field, or null when
+ * the field is unlocked or locked by the current user. Reads the SDK mirror the
+ * parent keeps in sync; the parent stamps `isOwn`. The lock display is now
+ * hover-driven (see mouseHover), so this is the single source consumers call on
+ * hover/click rather than a persistent page-wide paint.
+ *
+ * Container fields propagate the lock, mirroring the entry editor's
+ * useContainerFieldLock: a peer lock on an ancestor disables its descendants
+ * (parent locked => child locked) and a peer lock on a descendant disables the
+ * ancestor's structural actions (child locked => container locked). Mirror keys
+ * are cslp index-form, so the ancestor/descendant scan compares against
+ * fieldPathWithIndex.
+ */
+export function getPeerLockForField(
+ fieldMetadata: FieldMetadataForLock
+): EntryFieldLock | null {
+ const scopeLocks = getEntryFieldLockInfo({
+ entryUid: fieldMetadata.entry_uid,
+ locale: fieldMetadata.locale,
+ ...(fieldMetadata.variant ? { variantUid: fieldMetadata.variant } : {}),
+ });
+
+ const exact = asPeerLock(
+ scopeLocks[fieldMetadata.fieldPathWithIndex] ??
+ scopeLocks[fieldMetadata.fieldPath]
+ );
+ if (exact) return exact;
+
+ const target = fieldMetadata.fieldPathWithIndex || fieldMetadata.fieldPath;
+ if (!target) return null;
+ for (const lockedPath of Object.keys(scopeLocks)) {
+ if (
+ target.startsWith(lockedPath + ".") ||
+ lockedPath.startsWith(target + ".")
+ ) {
+ const peer = asPeerLock(scopeLocks[lockedPath]);
+ if (peer) return peer;
+ }
+ }
+ return null;
+}
+
+/** Avatar display (initials + colour + full name) for a lock's holder. */
+export function lockAvatarInfo(lock: EntryFieldLock): {
+ initials: string;
+ color: string;
+ name: string;
+} {
+ const user = lock.user ?? { uid: "" };
+ let initials = "?";
+ if (typeof user.initials === "string" && user.initials.trim()) {
+ initials = user.initials.trim();
+ } else if (typeof user.name === "string" && user.name.trim()) {
+ initials = user.name.trim().slice(0, 2).toUpperCase();
+ }
+ const color =
+ typeof user.avatarColor === "string" && user.avatarColor
+ ? user.avatarColor
+ : "#6c5ce7";
+ const name = typeof user.name === "string" ? user.name : "";
+ return { initials, color, name };
+}
+
+/** Entries whose snapshot has already been requested this session. */
+const requestedScopes = new Set();
+
+/**
+ * Requests an entry's lock snapshot at most once (the parent pushes deltas after,
+ * so re-asking is unnecessary) to seed the SDK mirror. Safe to call on every
+ * hover — it de-dupes per entry scope. A transient failure is not cached: the
+ * scope is released so a later hover retries, otherwise one blip would silence
+ * lock info for that entry for the whole session.
+ */
+export async function requestEntryLockInfoOnce(
+ scope: EntryLockScope
+): Promise {
+ const key = `${scope.entryUid}.${scope.locale}${
+ scope.variantUid ? `.${scope.variantUid}` : ""
+ }`;
+ if (requestedScopes.has(key)) {
+ return;
+ }
+ requestedScopes.add(key);
+
+ const result = await getEntryLockInfo(scope);
+ if (result === null) {
+ requestedScopes.delete(key);
+ }
+}
diff --git a/src/visualBuilder/utils/fieldLockStore.ts b/src/visualBuilder/utils/fieldLockStore.ts
new file mode 100644
index 00000000..b2b55ee0
--- /dev/null
+++ b/src/visualBuilder/utils/fieldLockStore.ts
@@ -0,0 +1,105 @@
+/**
+ * SDK-side read-only mirror of entry field-lock state.
+ *
+ * The parent window is the single source of truth for locks. The SDK keeps this
+ * mirror in sync via the initial snapshot (getEntryLockInfo) and subsequent
+ * pushes from the parent (ENTRY_LOCK_INFO_UPDATE). Consumers read from here to
+ * render indicators and gate actions; they never mutate lock state directly.
+ */
+
+export interface EntryFieldLock {
+ user: {
+ uid: string;
+ name?: string;
+ initials?: string;
+ avatarColor?: string;
+ [key: string]: unknown;
+ };
+ ttl: string;
+ isLocked: boolean;
+ /**
+ * True when the lock belongs to the current session. The parent computes
+ * this (the SDK knows neither the current user nor its socket id); a peer
+ * lock is `isLocked && !isOwn`. Absent means treat as a peer lock.
+ */
+ isOwn?: boolean;
+}
+
+export type EntryFieldLockInfo = Record;
+
+/** The parts that identify a lock scope: entry + locale + variant. */
+export interface EntryLockScopeParts {
+ entryUid: string;
+ locale: string;
+ variantUid?: string;
+}
+
+/** Scope parts plus the content type needed to build the lock-status request. */
+export interface EntryLockScope extends EntryLockScopeParts {
+ contentTypeUid: string;
+}
+
+const store = new Map();
+// Per-scope monotonic write counter, so a late snapshot can detect that a newer
+// delta already updated the scope and skip its stale overwrite.
+const scopeVersions = new Map();
+let writeSeq = 0;
+// Consumers (hover paint, field label) subscribe so the affordance re-renders
+// the moment the mirror changes, not only on the next mouse move.
+const lockListeners = new Set<() => void>();
+
+function notifyLockListeners(): void {
+ lockListeners.forEach((listener) => {
+ try {
+ listener();
+ } catch (error) {
+ console.debug("[Visual Builder] lock listener failed", error);
+ }
+ });
+}
+
+export function subscribeEntryFieldLockInfo(listener: () => void): () => void {
+ lockListeners.add(listener);
+ return () => {
+ lockListeners.delete(listener);
+ };
+}
+
+/**
+ * Lock scope key: entry + locale + variant, mirroring the parent's entry key and
+ * the Redis presence channel (both of which exclude content type). Content type
+ * is only needed to build the lock-status request, not to identify the scope.
+ */
+export function entryLockScopeKey({
+ entryUid,
+ locale,
+ variantUid,
+}: EntryLockScopeParts): string {
+ return `${entryUid}.${locale}${variantUid ? `.${variantUid}` : ""}`;
+}
+
+export function getEntryFieldLockVersion(scope: EntryLockScopeParts): number {
+ return scopeVersions.get(entryLockScopeKey(scope)) ?? 0;
+}
+
+export function setEntryFieldLockInfo(
+ scope: EntryLockScopeParts,
+ fieldLockInfo: EntryFieldLockInfo
+): void {
+ const key = entryLockScopeKey(scope);
+ store.set(key, fieldLockInfo ?? {});
+ scopeVersions.set(key, ++writeSeq);
+ notifyLockListeners();
+}
+
+export function getEntryFieldLockInfo(
+ scope: EntryLockScopeParts
+): EntryFieldLockInfo {
+ return store.get(entryLockScopeKey(scope)) ?? {};
+}
+
+export function clearAllEntryFieldLockInfo(): void {
+ store.clear();
+ scopeVersions.clear();
+ notifyLockListeners();
+}
diff --git a/src/visualBuilder/utils/getEntryLockInfo.ts b/src/visualBuilder/utils/getEntryLockInfo.ts
new file mode 100644
index 00000000..6fe8480a
--- /dev/null
+++ b/src/visualBuilder/utils/getEntryLockInfo.ts
@@ -0,0 +1,53 @@
+import { VisualBuilderPostMessageEvents } from "./types/postMessage.types";
+import visualBuilderPostMessage from "./visualBuilderPostMessage";
+import {
+ EntryFieldLockInfo,
+ EntryLockScope,
+ getEntryFieldLockVersion,
+ setEntryFieldLockInfo,
+} from "./fieldLockStore";
+
+/**
+ * Asks the parent for the current field-lock snapshot of an entry and stores it
+ * in the SDK mirror. The parent fetches it from the lock-status route and
+ * subscribes to the entry's presence channel so it can push later updates.
+ * Returns `null` (rather than throwing) when the read fails, so the caller can
+ * tell a genuine "no locks" snapshot ({}) apart from a transient failure and
+ * retry the latter.
+ */
+export async function getEntryLockInfo(
+ scope: EntryLockScope
+): Promise {
+ // Snapshot the scope version before the round-trip so we can tell if a newer
+ // delta (ENTRY_LOCK_INFO_UPDATE) landed while we waited and avoid clobbering
+ // it with this now-stale snapshot.
+ const versionBeforeRequest = getEntryFieldLockVersion(scope);
+ try {
+ const response = await visualBuilderPostMessage?.send<{
+ fieldLockInfo?: EntryFieldLockInfo;
+ error?: boolean;
+ }>(VisualBuilderPostMessageEvents.GET_ENTRY_LOCK_INFO, { ...scope });
+
+ // The parent flags a genuine fetch failure with `error: true` (not an
+ // empty map), so a transient blip is retried instead of being cached as
+ // "no locks" for the rest of the session.
+ if (!response || response.error || response.fieldLockInfo == null) {
+ return null;
+ }
+
+ const fieldLockInfo = response.fieldLockInfo;
+ // Only seed the mirror if no delta updated this scope during the
+ // round-trip; a delta that arrived meanwhile is fresher than this
+ // snapshot, so keep it.
+ if (getEntryFieldLockVersion(scope) === versionBeforeRequest) {
+ setEntryFieldLockInfo(scope, fieldLockInfo);
+ }
+ return fieldLockInfo;
+ } catch (error) {
+ console.debug(
+ "[Visual Builder] Error fetching entry lock info",
+ error
+ );
+ return null;
+ }
+}
diff --git a/src/visualBuilder/utils/types/postMessage.types.ts b/src/visualBuilder/utils/types/postMessage.types.ts
index 54ce0f87..2a85459b 100644
--- a/src/visualBuilder/utils/types/postMessage.types.ts
+++ b/src/visualBuilder/utils/types/postMessage.types.ts
@@ -33,6 +33,7 @@ export enum VisualBuilderPostMessageEvents {
GET_WORKFLOW_STAGE_DETAILS = "get-workflow-stage-details",
GET_RESOLVED_VARIANT_PERMISSIONS = "get-resolved-variant-permissions",
OPEN_REQUEST_EDIT_ACCESS = "open-request-edit-access",
+ GET_ENTRY_LOCK_INFO = "get-entry-lock-info",
// FROM visual builder
GET_ALL_ENTRIES_IN_CURRENT_PAGE = "get-entries-in-current-page",
@@ -61,6 +62,7 @@ export enum VisualBuilderPostMessageEvents {
TOGGLE_SCROLL = "toggle-scroll",
PAGE_CONTEXT = "page-context",
REQUEST_DISCUSSION_HIGHLIGHTS = "request-discussion-highlights",
+ ENTRY_LOCK_INFO_UPDATE = "entry-lock-info-update",
}
export interface IPageContextPostMessageEvent {
diff --git a/src/visualBuilder/visualBuilder.style.ts b/src/visualBuilder/visualBuilder.style.ts
index 2f394cca..d5d6a7ce 100644
--- a/src/visualBuilder/visualBuilder.style.ts
+++ b/src/visualBuilder/visualBuilder.style.ts
@@ -710,6 +710,27 @@ export function visualBuilderStyles() {
"visual-builder__draft-field": css`
outline: 2px dashed #eb5646;
`,
+ "visual-builder__lock-avatar": css`
+ position: absolute;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-weight: 600;
+ line-height: 1;
+ color: #ffffff;
+ background: #6c5ce7;
+ box-shadow: 0 0 0 2px #ffffff;
+ pointer-events: none;
+ transition: var(--outline-transition);
+ z-index: 2147483647 !important;
+ `,
+ "visual-builder__lock-avatar--hidden": css`
+ display: none;
+ `,
"visual-builder__variant-field": css``,
"visual-builder__variant-field-outline": css`
outline: 2px solid #bd59fa;