diff --git a/packages/live-preview-sdk/src/inspectorMode/__tests__/addCalculatedAttributesToTaggedElements.test.ts b/packages/live-preview-sdk/src/inspectorMode/__tests__/addCalculatedAttributesToTaggedElements.test.ts new file mode 100644 index 00000000..55c20591 --- /dev/null +++ b/packages/live-preview-sdk/src/inspectorMode/__tests__/addCalculatedAttributesToTaggedElements.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; + +import { InspectorModeDataAttributes } from '../types.js'; +import { addCalculatedAttributesToTaggedElements, getAllTaggedElements } from '../utils.js'; + +describe('addCalculatedAttributesToTaggedElements', () => { + const dataEntry = InspectorModeDataAttributes.ENTRY_ID; + const dataField = InspectorModeDataAttributes.FIELD_ID; + const dataLocale = InspectorModeDataAttributes.LOCALE; + + const html = (text: string) => { + return new DOMParser().parseFromString(text, 'text/html'); + }; + + describe('visibility attributes', () => { + it('returns isCoveredByOtherElement as false if no element is covering tagged element', () => { + const dom = html(` +
+
+
+ `); + + dom.getElementById('entry-1')!.checkVisibility = () => true; + const entry1Coordinates = { + bottom: 100, + height: 100, + left: 10, + right: 100, + top: 10, + width: 100, + x: 10, + y: 10, + }; + const entry1BoundingClientRect = { + ...entry1Coordinates, + toJSON: () => entry1Coordinates, + }; + dom.getElementById('entry-1')!.getBoundingClientRect = () => entry1BoundingClientRect; + + const { taggedElements: elements } = getAllTaggedElements({ + root: dom, + options: { locale: 'locale-1' }, + }); + + const entry1 = dom.getElementById('entry-1'); + + dom.elementFromPoint = (_x: number, _y: number) => { + return entry1; + }; + + const elementsWithCalculatedAttributes = addCalculatedAttributesToTaggedElements( + elements, + dom, + ); + expect(elementsWithCalculatedAttributes).toEqual([ + { + attributes: { + entryId: 'entry-1', + environment: undefined, + fieldId: 'field-1', + locale: 'locale-1', + space: undefined, + manuallyTagged: true, + }, + element: dom.getElementById('entry-1'), + isVisible: true, + isCoveredByOtherElement: false, + coordinates: entry1BoundingClientRect, + }, + ]); + }); + + it('returns isCoveredByOtherElement as true if an element is covering tagged element', () => { + const dom = html(` +
+
+
+
+ `); + + dom.getElementById('entry-1')!.checkVisibility = () => true; + const entry1Coordinates = { + bottom: 100, + height: 100, + left: 10, + right: 100, + top: 10, + width: 100, + x: 10, + y: 10, + }; + const entry1BoundingClientRect = { + ...entry1Coordinates, + toJSON: () => entry1Coordinates, + }; + dom.getElementById('entry-1')!.getBoundingClientRect = () => entry1BoundingClientRect; + + const { taggedElements: elements } = getAllTaggedElements({ + root: dom, + options: { locale: 'locale-1' }, + }); + + const coveringElement = dom.getElementById('covering-element'); + + dom.elementFromPoint = (_x: number, _y: number) => { + return coveringElement; + }; + + const elementsWithCalculatedAttributes = addCalculatedAttributesToTaggedElements( + elements, + dom, + ); + expect(elementsWithCalculatedAttributes).toEqual([ + { + attributes: { + entryId: 'entry-1', + environment: undefined, + fieldId: 'field-1', + locale: 'locale-1', + space: undefined, + manuallyTagged: true, + }, + element: dom.getElementById('entry-1'), + isVisible: true, + isCoveredByOtherElement: true, + coordinates: entry1BoundingClientRect, + }, + ]); + }); + }); +}); diff --git a/packages/live-preview-sdk/src/inspectorMode/index.ts b/packages/live-preview-sdk/src/inspectorMode/index.ts index 049ae968..70d3e8dc 100644 --- a/packages/live-preview-sdk/src/inspectorMode/index.ts +++ b/packages/live-preview-sdk/src/inspectorMode/index.ts @@ -5,10 +5,13 @@ import { type MessageFromEditor } from '../messages.js'; import { InspectorModeDataAttributes, InspectorModeEventMethods, - type InspectorModeAttributes, type InspectorModeChangedMessage, } from './types.js'; -import { getAllTaggedElements } from './utils.js'; +import { + addCalculatedAttributesToTaggedElements, + getAllTaggedElements, + TaggedElement, +} from './utils.js'; export type InspectorModeOptions = { locale: string; @@ -18,13 +21,6 @@ export type InspectorModeOptions = { ignoreManuallyTaggedElements?: boolean; }; -type TaggedElement = { - attributes: InspectorModeAttributes | null; - coordinates: DOMRect; - element: Element; - isVisible: boolean; -}; - export class InspectorMode { private delay = 300; @@ -239,10 +235,11 @@ export class InspectorMode { { elements: this.taggedElements.map((taggedElement) => ({ // Important: do not add `element` as it can't be cloned by sendMessage - coordinates: taggedElement.coordinates, - isVisible: taggedElement.isVisible, + coordinates: taggedElement.coordinates!, + isVisible: !!taggedElement.isVisible, attributes: taggedElement.attributes, isHovered: this.hoveredElement === taggedElement.element, + isCoveredByOtherElement: !!taggedElement.isCoveredByOtherElement, })), automaticallyTaggedCount: this.automaticallyTaggedCount, manuallyTaggedCount: this.manuallyTaggedCount, @@ -260,15 +257,7 @@ export class InspectorMode { options: this.options, }); - const nextElements = taggedElements.map(({ element, attributes }) => ({ - element, - coordinates: element.getBoundingClientRect(), - attributes, - isVisible: element.checkVisibility({ - checkOpacity: true, - checkVisibilityCSS: true, - }), - })); + const nextElements = addCalculatedAttributesToTaggedElements(taggedElements); if (isEqual(nextElements, this.taggedElements)) { return; @@ -279,7 +268,7 @@ export class InspectorMode { this.observersCB = []; // update elements and watch them - this.taggedElements = nextElements; + this.taggedElements = nextElements as TaggedElement[]; taggedElements.forEach(({ element }) => this.observe(element)); // update the counters for telemetry diff --git a/packages/live-preview-sdk/src/inspectorMode/utils.ts b/packages/live-preview-sdk/src/inspectorMode/utils.ts index 45adf37d..7f7fc949 100644 --- a/packages/live-preview-sdk/src/inspectorMode/utils.ts +++ b/packages/live-preview-sdk/src/inspectorMode/utils.ts @@ -16,11 +16,18 @@ export type AutoTaggedElement = { sourceMap: SourceMapMetadata; }; -interface TaggedElement { +interface PrecaulculatedTaggedElement { element: Element; attributes: InspectorModeAttributes | null; } +export interface TaggedElement extends PrecaulculatedTaggedElement { + isHovered: boolean; + isVisible: boolean; + coordinates: DOMRect; + isCoveredByOtherElement: boolean; +} + const isTaggedElement = (node?: Node | null): boolean => { if (!node) { return false; @@ -132,7 +139,10 @@ function getNodeText(node: HTMLElement): string { .join(''); } -function hasTaggedParent(node: HTMLElement, taggedElements: TaggedElement[]): boolean { +function hasTaggedParent( + node: HTMLElement, + taggedElements: PrecaulculatedTaggedElement[], +): boolean { for (const tagged of taggedElements) { if (tagged.element === node || tagged.element.contains(node)) { return true; @@ -154,7 +164,7 @@ export function getAllTaggedElements({ options: Omit; ignoreManual?: boolean; }): { - taggedElements: TaggedElement[]; + taggedElements: PrecaulculatedTaggedElement[]; manuallyTaggedCount: number; automaticallyTaggedCount: number; autoTaggedElements: AutoTaggedElement[]; @@ -166,7 +176,7 @@ export function getAllTaggedElements({ ); //Spread operator is necessary to convert the NodeList to an array - const taggedElements: TaggedElement[] = [...alreadyTagged] + const taggedElements: PrecaulculatedTaggedElement[] = [...alreadyTagged] .map((element: Element) => ({ element, attributes: getManualInspectorModeAttributes(element, options), @@ -261,7 +271,7 @@ export function getAllTaggedEntries({ return [ ...new Set( getAllTaggedElements({ options }) - .taggedElements.map((element: TaggedElement) => { + .taggedElements.map((element: PrecaulculatedTaggedElement) => { if (element.attributes && 'entryId' in element.attributes) { return element.attributes.entryId; } @@ -271,3 +281,53 @@ export function getAllTaggedEntries({ ), ]; } + +const isElementOverlapped = (element: Element, coordinates: DOMRect, root = window.document) => { + const { top, right, bottom, left } = coordinates; + const topLeft = root.elementFromPoint(left, top); + const topRight = root.elementFromPoint(right, top); + const bottomLeft = root.elementFromPoint(left, bottom); + const bottomRight = root.elementFromPoint(right, bottom); + + return ( + topLeft === element || topRight === element || bottomLeft === element || bottomRight === element + ); +}; + +const addVisibilityToTaggedElements = ( + taggedElements: Partial[], + root = window.document, +) => + taggedElements.map((taggedElement) => ({ + ...taggedElement, + isVisible: taggedElement.element!.checkVisibility({ + checkOpacity: true, + checkVisibilityCSS: true, + }), + isCoveredByOtherElement: !isElementOverlapped( + taggedElement.element!, + taggedElement.coordinates!, + root, + ), + })); + +const addCoordinatesToTaggedElements = ( + taggedElements: Partial[], +): Partial[] => + taggedElements.map(({ element, attributes }) => ({ + element, + coordinates: element!.getBoundingClientRect(), + attributes, + })); + +/** + * Applies the attributes that we cannot simply get from the tagged elements itself + * but need to calculate based on the current state of the document + */ +export const addCalculatedAttributesToTaggedElements = ( + taggedElements: PrecaulculatedTaggedElement[], + root = window.document, +): TaggedElement[] => { + const taggedElementWithCoordinates = addCoordinatesToTaggedElements(taggedElements); + return addVisibilityToTaggedElements(taggedElementWithCoordinates, root) as TaggedElement[]; +};