diff --git a/README.md b/README.md index f717def9..eed5c609 100644 --- a/README.md +++ b/README.md @@ -102,11 +102,8 @@ To use the inspector mode, you need to tag fields by adding the live preview dat You can do this in React via our helper function. -The necessary styles for the live edit tags can be found in the `@contentful/live-preview/style.css` file. - ```jsx import { ContentfulLivePreview } from '@contentful/live-preview'; -import '@contentful/live-preview/style.css'; ...

@@ -197,18 +194,13 @@ or npm install @contentful/live-preview ``` -2. Once you've got the data from Contentful, then you can initialize the live preview. You can use the `ContentfulLivePreview` class' [init function](#init-configuration) and add the stylesheet for field tagging as a stylesheet link. +2. Once you've got the data from Contentful, then you can initialize the live preview. You can use the `ContentfulLivePreview` class' [init function](#init-configuration). ```html Live Preview Example - diff --git a/examples/vanilla-js/package.json b/examples/vanilla-js/package.json index 6555e6db..450d8b3c 100644 --- a/examples/vanilla-js/package.json +++ b/examples/vanilla-js/package.json @@ -11,7 +11,7 @@ "author": "", "license": "MIT", "dependencies": { - "@contentful/live-preview": "^2.9.1", + "@contentful/live-preview": "latest", "contentful": "^10.5.0", "dotenv": "^16.3.1" }, diff --git a/packages/live-preview-sdk/src/__tests__/index.spec.ts b/packages/live-preview-sdk/src/__tests__/index.spec.ts index 2a17925e..1df2e403 100644 --- a/packages/live-preview-sdk/src/__tests__/index.spec.ts +++ b/packages/live-preview-sdk/src/__tests__/index.spec.ts @@ -6,13 +6,23 @@ import { ContentfulLivePreview } from '../index'; import { InspectorMode } from '../inspectorMode'; import { LiveUpdates } from '../liveUpdates'; import { SaveEvent } from '../saveEvent'; -import { TagAttributes } from '../types'; +import { InspectorModeDataAttributes } from '../inspectorMode/types'; vi.mock('../inspectorMode'); vi.mock('../liveUpdates'); vi.mock('../saveEvent'); vi.mock('../helpers'); +const ObserverMock = vi.fn(() => ({ + disconnect: vi.fn(), + observe: vi.fn(), + takeRecords: vi.fn(), + unobserve: vi.fn(), +})); + +vi.stubGlobal('ResizeObserver', ObserverMock); +vi.stubGlobal('MutationObserver', ObserverMock); + describe('ContentfulLivePreview', () => { const receiveMessageInspectorMode = vi.fn(); const receiveMessageLiveUpdates = vi.fn(); @@ -125,9 +135,9 @@ describe('ContentfulLivePreview', () => { }); expect(result).toStrictEqual({ - [TagAttributes.FIELD_ID]: fieldId, - [TagAttributes.ENTRY_ID]: entryId, - [TagAttributes.LOCALE]: locale, + [InspectorModeDataAttributes.FIELD_ID]: fieldId, + [InspectorModeDataAttributes.ENTRY_ID]: entryId, + [InspectorModeDataAttributes.LOCALE]: locale, }); }); diff --git a/packages/live-preview-sdk/src/__tests__/init.spec.ts b/packages/live-preview-sdk/src/__tests__/init.spec.ts index c2feab11..a06d8362 100644 --- a/packages/live-preview-sdk/src/__tests__/init.spec.ts +++ b/packages/live-preview-sdk/src/__tests__/init.spec.ts @@ -9,6 +9,16 @@ import { LiveUpdates } from '../liveUpdates'; vi.mock('../helpers'); +const ObserverMock = vi.fn(() => ({ + disconnect: vi.fn(), + observe: vi.fn(), + takeRecords: vi.fn(), + unobserve: vi.fn(), +})); + +vi.stubGlobal('ResizeObserver', ObserverMock); +vi.stubGlobal('MutationObserver', ObserverMock); + describe('init', () => { beforeEach(() => { (isInsideIframe as Mock).mockReturnValue(true); diff --git a/packages/live-preview-sdk/src/__tests__/inspectorMode.spec.ts b/packages/live-preview-sdk/src/__tests__/inspectorMode.spec.ts index 5b708f7b..4b2e3fb3 100644 --- a/packages/live-preview-sdk/src/__tests__/inspectorMode.spec.ts +++ b/packages/live-preview-sdk/src/__tests__/inspectorMode.spec.ts @@ -3,10 +3,23 @@ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; import { LIVE_PREVIEW_EDITOR_SOURCE } from '../constants'; import { InspectorMode } from '../inspectorMode'; -import { LivePreviewPostMessageMethods } from '../messages'; +import { InspectorModeEventMethods, LivePreviewPostMessageMethods } from '../messages'; +import { sendMessageToEditor } from '../helpers'; + +vi.mock('../helpers'); const locale = 'en-US'; +const ObserverMock = vi.fn(() => ({ + disconnect: vi.fn(), + observe: vi.fn(), + takeRecords: vi.fn(), + unobserve: vi.fn(), +})); + +vi.stubGlobal('ResizeObserver', ObserverMock); +vi.stubGlobal('MutationObserver', ObserverMock); + describe('InspectorMode', () => { let inspectorMode: InspectorMode; const targetOrigin = ['https://app.contentful.com']; @@ -30,16 +43,23 @@ describe('InspectorMode', () => { expect(spy).not.toHaveBeenCalled(); }); - test('should toggle "contentful-inspector--active" class on document.body based on value of isInspectorActive', () => { - const spy = vi.spyOn(document.body.classList, 'toggle'); + test('should send the tagged elements back to the editor', () => { inspectorMode.receiveMessage({ - action: LivePreviewPostMessageMethods.INSPECTOR_MODE_CHANGED, + action: InspectorModeEventMethods.INSPECTOR_MODE_CHANGED, from: 'live-preview', - method: LivePreviewPostMessageMethods.INSPECTOR_MODE_CHANGED, + method: InspectorModeEventMethods.INSPECTOR_MODE_CHANGED, source: LIVE_PREVIEW_EDITOR_SOURCE, isInspectorActive: true, }); - expect(spy).toHaveBeenCalledWith('contentful-inspector--active', true); + + expect(sendMessageToEditor).toHaveBeenCalledOnce(); + expect(sendMessageToEditor).toHaveBeenCalledWith( + InspectorModeEventMethods.TAGGED_ELEMENTS, + { + elements: [], + }, + ['https://app.contentful.com'] + ); }); }); }); diff --git a/packages/live-preview-sdk/src/__tests__/liveUpdates.spec.ts b/packages/live-preview-sdk/src/__tests__/liveUpdates.spec.ts index 0fb81039..9db47220 100644 --- a/packages/live-preview-sdk/src/__tests__/liveUpdates.spec.ts +++ b/packages/live-preview-sdk/src/__tests__/liveUpdates.spec.ts @@ -5,7 +5,7 @@ import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest'; import { LIVE_PREVIEW_EDITOR_SOURCE } from '../constants'; import * as helpers from '../helpers'; import { LiveUpdates } from '../liveUpdates'; -import { LivePreviewPostMessageMethods } from '../messages'; +import { InspectorModeEventMethods, LivePreviewPostMessageMethods } from '../messages'; import { ContentType } from '../types'; import assetFromEntryEditor from './fixtures/assetFromEntryEditor.json'; import landingPageContentType from './fixtures/landingPageContentType.json'; @@ -152,8 +152,8 @@ describe('LiveUpdates', () => { await liveUpdates.receiveMessage({ isInspectorActive: false, - action: LivePreviewPostMessageMethods.INSPECTOR_MODE_CHANGED, - method: LivePreviewPostMessageMethods.INSPECTOR_MODE_CHANGED, + action: InspectorModeEventMethods.INSPECTOR_MODE_CHANGED, + method: InspectorModeEventMethods.INSPECTOR_MODE_CHANGED, from: 'live-preview', source: LIVE_PREVIEW_EDITOR_SOURCE, }); diff --git a/packages/live-preview-sdk/src/__tests__/saveEvent.spec.ts b/packages/live-preview-sdk/src/__tests__/saveEvent.spec.ts index 266bcd47..b5eea4ff 100644 --- a/packages/live-preview-sdk/src/__tests__/saveEvent.spec.ts +++ b/packages/live-preview-sdk/src/__tests__/saveEvent.spec.ts @@ -1,12 +1,12 @@ import { Entry } from 'contentful'; import { describe, beforeEach, vi, it, afterEach, Mock, expect } from 'vitest'; -import { getAllTaggedEntries } from '../fieldTaggingUtils'; import { EntrySavedMessage, LivePreviewPostMessageMethods } from '../messages'; import { SaveEvent } from '../saveEvent'; import { ContentType } from '../types'; +import { getAllTaggedEntries } from '../inspectorMode/utils'; -vi.mock('../fieldTaggingUtils'); +vi.mock('../inspectorMode/utils'); describe('SaveEvent', () => { const locale = 'en-US'; diff --git a/packages/live-preview-sdk/src/constants.ts b/packages/live-preview-sdk/src/constants.ts index 70657b26..30cd2508 100644 --- a/packages/live-preview-sdk/src/constants.ts +++ b/packages/live-preview-sdk/src/constants.ts @@ -1,13 +1,3 @@ -import { TagAttributes } from './types'; - -export const DATA_CURR_FIELD_ID = `current-${TagAttributes.FIELD_ID}`; -export const DATA_CURR_ENTRY_ID = `current-${TagAttributes.ENTRY_ID}`; -export const DATA_CURR_LOCALE = `current-${TagAttributes.LOCALE}`; -export const TOOLTIP_CLASS = 'contentful-tooltip'; - -export const TOOLTIP_HEIGHT = 32; -export const TOOLTIP_PADDING_LEFT = 5; - export const MAX_DEPTH = 10; export const LIVE_PREVIEW_EDITOR_SOURCE = 'live-preview-editor' as const; diff --git a/packages/live-preview-sdk/src/fieldTaggingUtils.ts b/packages/live-preview-sdk/src/fieldTaggingUtils.ts deleted file mode 100644 index f3a4e6cf..00000000 --- a/packages/live-preview-sdk/src/fieldTaggingUtils.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { TagAttributes } from './types'; - -/** - * Returns a list of tagged entries on the page - */ -export function getAllTaggedEntries(): string[] { - return [ - ...new Set( - [...document.querySelectorAll(`[${TagAttributes.ENTRY_ID}]`)] - .map((element) => element.getAttribute(TagAttributes.ENTRY_ID)) - .filter(Boolean) as string[] - ), - ]; -} diff --git a/packages/live-preview-sdk/src/helpers/pollUrlChanges.ts b/packages/live-preview-sdk/src/helpers/pollUrlChanges.ts index 65845615..d3778e18 100644 --- a/packages/live-preview-sdk/src/helpers/pollUrlChanges.ts +++ b/packages/live-preview-sdk/src/helpers/pollUrlChanges.ts @@ -1,3 +1,4 @@ +// TODO: instead of polling could we use MutationObserver to check for dom changes and then compare URL's export function pollUrlChanges(callback: (newUrl: string) => void, interval = 500): () => void { let initialUrl = window.location.href; diff --git a/packages/live-preview-sdk/src/helpers/utils.ts b/packages/live-preview-sdk/src/helpers/utils.ts index 26e5872a..6f118f7d 100644 --- a/packages/live-preview-sdk/src/helpers/utils.ts +++ b/packages/live-preview-sdk/src/helpers/utils.ts @@ -13,14 +13,14 @@ export function sendMessageToEditor( data: EditorMessage, targetOrigin: string[] ): void { - const message: MessageFromSDK = { + const message = { ...data, method, from: 'live-preview', source: LIVE_PREVIEW_SDK_SOURCE, location: window.location.href, version, - }; + } as MessageFromSDK; debug.log('Send message', message); diff --git a/packages/live-preview-sdk/src/index.ts b/packages/live-preview-sdk/src/index.ts index fd1c7f31..6394aabd 100644 --- a/packages/live-preview-sdk/src/index.ts +++ b/packages/live-preview-sdk/src/index.ts @@ -3,7 +3,6 @@ import './styles.css'; import { type DocumentNode } from 'graphql'; import { version } from '../package.json'; -import { getAllTaggedEntries } from './fieldTaggingUtils'; import { sendMessageToEditor, pollUrlChanges, @@ -13,24 +12,20 @@ import { } from './helpers'; import { isValidMessage } from './helpers/validateMessage'; import { InspectorMode } from './inspectorMode'; +import { type InspectorModeTags, InspectorModeDataAttributes } from './inspectorMode/types'; +import { getAllTaggedEntries } from './inspectorMode/utils'; import { LiveUpdates } from './liveUpdates'; import { - ConnectedMessage, - EditorMessage, + type ConnectedMessage, + type EditorMessage, + type MessageFromEditor, + type PostMessageMethods, + type UrlChangedMessage, LivePreviewPostMessageMethods, - MessageFromEditor, - PostMessageMethods, - UrlChangedMessage, openEntryInEditorUtility, } from './messages'; import { SaveEvent } from './saveEvent'; -import { - Argument, - InspectorModeTags, - LivePreviewProps, - SubscribeCallback, - TagAttributes, -} from './types'; +import type { Argument, LivePreviewProps, SubscribeCallback } from './types'; export const VERSION = version; @@ -156,14 +151,18 @@ export class ContentfulLivePreview { LivePreviewPostMessageMethods.URL_CHANGED, { action: LivePreviewPostMessageMethods.URL_CHANGED, - taggedElementCount: document.querySelectorAll(`[${TagAttributes.ENTRY_ID}]`).length, + taggedElementCount: document.querySelectorAll( + `[${InspectorModeDataAttributes.ENTRY_ID}]` + ).length, } as UrlChangedMessage, this.targetOrigin ); }); // tell the editor that there's a SDK - const taggedElementCount = document.querySelectorAll(`[${TagAttributes.ENTRY_ID}]`).length; + const taggedElementCount = document.querySelectorAll( + `[${InspectorModeDataAttributes.ENTRY_ID}]` + ).length; sendMessageToEditor( LivePreviewPostMessageMethods.CONNECTED, { @@ -234,9 +233,9 @@ export class ContentfulLivePreview { } return { - [TagAttributes.FIELD_ID]: fieldId, - [TagAttributes.ENTRY_ID]: entryId, - [TagAttributes.LOCALE]: locale, + [InspectorModeDataAttributes.FIELD_ID]: fieldId, + [InspectorModeDataAttributes.ENTRY_ID]: entryId, + [InspectorModeDataAttributes.LOCALE]: locale, }; } diff --git a/packages/live-preview-sdk/src/inspectorMode.ts b/packages/live-preview-sdk/src/inspectorMode.ts deleted file mode 100644 index 87b161bf..00000000 --- a/packages/live-preview-sdk/src/inspectorMode.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - DATA_CURR_ENTRY_ID, - DATA_CURR_FIELD_ID, - DATA_CURR_LOCALE, - TOOLTIP_CLASS, - TOOLTIP_HEIGHT, - TOOLTIP_PADDING_LEFT, -} from './constants'; -import { - InspectorModeChangedMessage, - LivePreviewPostMessageMethods, - MessageFromEditor, - openEntryInEditorUtility, -} from './messages'; -import { TagAttributes } from './types'; - -export class InspectorMode { - private tooltip: HTMLButtonElement | null = null; // this tooltip scrolls to the correct field in the entry editor - private currentElementBesideTooltip: HTMLElement | null = null; // this element helps to position the tooltip - private defaultLocale: string; - private targetOrigin: string[]; - - constructor({ locale, targetOrigin }: { locale: string; targetOrigin: string[] }) { - this.tooltip = null; - this.currentElementBesideTooltip = null; - this.defaultLocale = locale; - this.targetOrigin = targetOrigin; - - this.updateTooltipPosition = this.updateTooltipPosition.bind(this); - this.addTooltipOnHover = this.addTooltipOnHover.bind(this); - this.createTooltip = this.createTooltip.bind(this); - this.clickHandler = this.clickHandler.bind(this); - - this.createTooltip(); - window.addEventListener('scroll', this.updateTooltipPosition); - window.addEventListener('mouseover', this.addTooltipOnHover); - } - - // Handles incoming messages from Contentful - public receiveMessage(data: MessageFromEditor): void { - if ( - ('action' in data && data.action === 'INSPECTOR_MODE_CHANGED') || - data.method === LivePreviewPostMessageMethods.INSPECTOR_MODE_CHANGED - ) { - // Toggle the contentful-inspector--active class on the body element based on the isInspectorActive boolean - document.body.classList.toggle( - 'contentful-inspector--active', - (data as InspectorModeChangedMessage).isInspectorActive - ); - } - } - - // Updates the position of the tooltip - private updateTooltipPosition() { - if (!this.currentElementBesideTooltip || !this.tooltip) return false; - - const currentRectOfElement = this.currentElementBesideTooltip.getBoundingClientRect(); - const currentRectOfParentOfElement = this.tooltip.parentElement?.getBoundingClientRect(); - - if (currentRectOfElement && currentRectOfParentOfElement) { - let upperBoundOfTooltip = currentRectOfElement.top - TOOLTIP_HEIGHT; - const left = currentRectOfElement.left - TOOLTIP_PADDING_LEFT; - - if (upperBoundOfTooltip < 0) { - if (currentRectOfElement.top < 0) upperBoundOfTooltip = currentRectOfElement.top; - else upperBoundOfTooltip = 0; - } - - this.tooltip.style.top = upperBoundOfTooltip + 'px'; - this.tooltip.style.left = left + 'px'; - - return true; - } - - return false; - } - - private addTooltipOnHover(e: MouseEvent) { - const eventTargets = e.composedPath(); - - for (const eventTarget of eventTargets) { - const element = eventTarget as HTMLElement; - if (element.nodeName === 'BODY') break; - if (typeof element?.getAttribute !== 'function') continue; - - const currFieldId = element.getAttribute(TagAttributes.FIELD_ID); - const currEntryId = element.getAttribute(TagAttributes.ENTRY_ID); - const currLocale = element.getAttribute(TagAttributes.LOCALE) ?? this.defaultLocale; - - if (currFieldId && currEntryId && currLocale) { - this.currentElementBesideTooltip = element; - - if (this.updateTooltipPosition()) { - this.tooltip?.setAttribute(DATA_CURR_FIELD_ID, currFieldId); - this.tooltip?.setAttribute(DATA_CURR_ENTRY_ID, currEntryId); - this.tooltip?.setAttribute(DATA_CURR_LOCALE, currLocale); - } - - break; - } - } - } - - private createTooltip() { - if (!document.querySelector(`.${TOOLTIP_CLASS}`)) { - const tooltip = document.createElement('button'); - tooltip.classList.add(TOOLTIP_CLASS); - tooltip.innerHTML = ` - - Edit`; - window.document.body.insertAdjacentElement('beforeend', tooltip); - tooltip.addEventListener('click', this.clickHandler); - this.tooltip = tooltip; - } - this.updateTooltipPosition(); - } - - // responsible for handling the event when the user clicks on the edit button in the tooltip - private clickHandler() { - const fieldId = this.tooltip?.getAttribute(DATA_CURR_FIELD_ID); - const entryId = this.tooltip?.getAttribute(DATA_CURR_ENTRY_ID); - const locale = this.tooltip?.getAttribute(DATA_CURR_LOCALE) || this.defaultLocale; - - if (fieldId && entryId && locale) { - openEntryInEditorUtility(fieldId, entryId, locale, this.targetOrigin); - } - } -} diff --git a/packages/live-preview-sdk/src/inspectorMode/index.ts b/packages/live-preview-sdk/src/inspectorMode/index.ts new file mode 100644 index 00000000..6db96a0a --- /dev/null +++ b/packages/live-preview-sdk/src/inspectorMode/index.ts @@ -0,0 +1,212 @@ +import { sendMessageToEditor } from '../helpers'; +import type { MessageFromEditor } from '../messages'; +import { + InspectorModeDataAttributes, + type InspectorModeChangedMessage, + InspectorModeEventMethods, +} from './types'; +import { getAllTaggedElements, getInspectorModeAttributes } from './utils'; + +export class InspectorMode { + private defaultLocale: string; + private targetOrigin: string[]; + + private isScrolling = false; + private scrollTimeout?: NodeJS.Timeout; + + private isResizing = false; + private resizeTimeout?: NodeJS.Timeout; + + private hoveredElement?: HTMLElement; + private taggedElements: Element[] = []; + + constructor({ locale, targetOrigin }: { locale: string; targetOrigin: string[] }) { + this.defaultLocale = locale; + this.targetOrigin = targetOrigin; + + this.bindHoverListener = this.bindHoverListener.bind(this); + this.bindScrollListener = this.bindScrollListener.bind(this); + this.bindMutationListener = this.bindMutationListener.bind(this); + this.bindResizeListener = this.bindResizeListener.bind(this); + + this.handleTaggedElement = this.handleTaggedElement.bind(this); + this.sendAllElements = this.sendAllElements.bind(this); + + // Attach interaction listeners + this.bindHoverListener(); + this.bindScrollListener(); + this.bindMutationListener(); + this.bindResizeListener(); + } + + // Handles incoming messages from Contentful + public receiveMessage(data: MessageFromEditor): void { + if (data.method === InspectorModeEventMethods.INSPECTOR_MODE_CHANGED) { + const { isInspectorActive } = data as InspectorModeChangedMessage; + if (isInspectorActive) { + this.sendAllElements(); + } + } + } + + /** Checks if the hovered element is an tagged entry and then sends it to the editor */ + private bindHoverListener() { + const onMouseOver = (e: MouseEvent) => { + const eventTargets = e.composedPath(); + + for (const eventTarget of eventTargets) { + const element = eventTarget as HTMLElement; + if (element.nodeName === 'BODY') break; + if (typeof element?.getAttribute !== 'function') continue; + + if (this.handleTaggedElement(element)) { + return; + } + } + + // Clear if no tagged element is hovered + if (this.hoveredElement) { + this.hoveredElement = undefined; + sendMessageToEditor( + InspectorModeEventMethods.MOUSE_MOVE, + { element: null }, + this.targetOrigin + ); + } + }; + + window.addEventListener('mouseover', onMouseOver); + + return () => window.removeEventListener('mouseover', onMouseOver); + } + + /** Sends scroll start and end event to the editor, on end it also sends the tagged elements again */ + private bindScrollListener() { + const onScroll = () => { + if (!this.isScrolling) { + this.isScrolling = true; + sendMessageToEditor(InspectorModeEventMethods.SCROLL_START, {}, this.targetOrigin); + } + + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout); + } + + this.scrollTimeout = setTimeout(() => { + // No longer scrolling, let's update everything + this.isScrolling = false; + sendMessageToEditor(InspectorModeEventMethods.SCROLL_END, {}, this.targetOrigin); + this.sendAllElements(); + if (this.hoveredElement) { + this.handleTaggedElement(this.hoveredElement); + } + }, 150); + }; + + window.addEventListener('scroll', onScroll); + + return () => window.removeEventListener('scroll', onScroll); + } + + /** Detects DOM changes and sends the tagged elements to the editor */ + private bindMutationListener() { + const mutationObserver = new MutationObserver(() => { + const taggedElements = getAllTaggedElements().filter( + (el) => !!getInspectorModeAttributes(el) + ); + + if (this.taggedElements?.length !== taggedElements.length) { + this.sendAllElements(); + } + }); + + mutationObserver.observe(document.body, { + attributes: true, + attributeFilter: [ + InspectorModeDataAttributes.ENTRY_ID, + InspectorModeDataAttributes.FIELD_ID, + InspectorModeDataAttributes.LOCALE, + ], + childList: true, + subtree: true, + }); + + return () => mutationObserver.disconnect(); + } + + /** Sends resize start and end event to the editor, on end it also sends the tagged elements again */ + private bindResizeListener() { + const resizeObserver = new ResizeObserver(() => { + if (!this.isResizing) { + this.isScrolling = true; + sendMessageToEditor(InspectorModeEventMethods.RESIZE_START, {}, this.targetOrigin); + } + + if (this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + } + + this.resizeTimeout = setTimeout(() => { + // No longer resizing, let's update everything + this.isScrolling = false; + sendMessageToEditor(InspectorModeEventMethods.RESIZE_END, {}, this.targetOrigin); + this.sendAllElements(); + if (this.hoveredElement) { + this.handleTaggedElement(this.hoveredElement); + } + }, 150); + }); + + resizeObserver.observe(document.body); + + return () => resizeObserver.disconnect(); + } + + /** + * Validates if the element has the inspector mode attributes + * and sends it then to the editor + */ + private handleTaggedElement(element: HTMLElement): boolean { + const taggedInformation = getInspectorModeAttributes(element, this.defaultLocale); + + if (!taggedInformation) { + return false; + } + + this.hoveredElement = element; + sendMessageToEditor( + InspectorModeEventMethods.MOUSE_MOVE, + { + element: { + attributes: taggedInformation, + coordinates: element.getBoundingClientRect(), + }, + }, + this.targetOrigin + ); + + return true; + } + + /** + * Finds all elements that have all inspector mode attributes + * and sends them to the editor + */ + private sendAllElements() { + const entries = getAllTaggedElements().filter( + (element) => !!getInspectorModeAttributes(element, this.defaultLocale) + ); + + this.taggedElements = entries; + + sendMessageToEditor( + InspectorModeEventMethods.TAGGED_ELEMENTS, + { + elements: entries.map((e) => ({ + coordinates: e.getBoundingClientRect(), + })), + }, + this.targetOrigin + ); + } +} diff --git a/packages/live-preview-sdk/src/inspectorMode/types.ts b/packages/live-preview-sdk/src/inspectorMode/types.ts new file mode 100644 index 00000000..0b7ae01e --- /dev/null +++ b/packages/live-preview-sdk/src/inspectorMode/types.ts @@ -0,0 +1,49 @@ +import type { SetRequired } from 'type-fest'; + +export type InspectorModeTags = { + [InspectorModeDataAttributes.ENTRY_ID]: string; + [InspectorModeDataAttributes.FIELD_ID]: string; + [InspectorModeDataAttributes.LOCALE]?: string; +} | null; + +export const enum InspectorModeDataAttributes { + FIELD_ID = 'data-contentful-field-id', + ENTRY_ID = 'data-contentful-entry-id', + LOCALE = 'data-contentful-locale', +} + +export enum InspectorModeEventMethods { + MOUSE_MOVE = 'MOUSE_MOVE', + SCROLL_START = 'SCROLL_START', + SCROLL_END = 'SCROLL_END', + RESIZE_START = 'RESIZE_START', + RESIZE_END = 'RESIZE_END', + TAGGED_ELEMENTS = 'TAGGED_ELEMENTS', + INSPECTOR_MODE_CHANGED = 'INSPECTOR_MODE_CHANGED', +} + +export type InspectorModeAttributes = { + entryId: string; + fieldId: string; + locale: string; +}; + +export type InspectorModeElement = { + attributes?: InspectorModeAttributes | null; + coordinates: DOMRect; +}; + +export type InspectorModeScrollMessage = Record; +export type InspectorModeResizeMessage = Record; +export type InspectorModeMouseMoveMessage = { + element: SetRequired | null; +}; +export type InspectorModeTaggedElementsMessage = { + elements: Array; +}; + +export type InspectorModeChangedMessage = { + /** @deprecated use method instead */ + action: InspectorModeEventMethods.INSPECTOR_MODE_CHANGED; + isInspectorActive: boolean; +}; diff --git a/packages/live-preview-sdk/src/inspectorMode/utils.ts b/packages/live-preview-sdk/src/inspectorMode/utils.ts new file mode 100644 index 00000000..7eaf2472 --- /dev/null +++ b/packages/live-preview-sdk/src/inspectorMode/utils.ts @@ -0,0 +1,42 @@ +import { InspectorModeAttributes, InspectorModeDataAttributes } from './types'; + +/** + * Parses the necessary information from the element and returns them. + * If **one** of the information is missing it returns null + */ +export function getInspectorModeAttributes( + element: Element, + fallbackLocale?: string +): InspectorModeAttributes | null { + const fieldId = element.getAttribute(InspectorModeDataAttributes.FIELD_ID); + const entryId = element.getAttribute(InspectorModeDataAttributes.ENTRY_ID); + const locale = element.getAttribute(InspectorModeDataAttributes.LOCALE) ?? fallbackLocale; + + if (!fieldId || !entryId || !locale) { + return null; + } + + return { fieldId, entryId, locale }; +} + +/** + * Query the document for all tagged elements + * **Attention:** Can include elements that have not all attributes, + * if you want to have only valid ones check for `getTaggedInformation` + */ +export function getAllTaggedElements(): Element[] { + return [...document.querySelectorAll(`[${InspectorModeDataAttributes.ENTRY_ID}]`)]; +} + +/** + * Returns a list of tagged entries on the page + */ +export function getAllTaggedEntries(): string[] { + return [ + ...new Set( + getAllTaggedElements() + .map((element) => element.getAttribute(InspectorModeDataAttributes.ENTRY_ID)) + .filter(Boolean) as string[] + ), + ]; +} diff --git a/packages/live-preview-sdk/src/messages.ts b/packages/live-preview-sdk/src/messages.ts index 8bad2f64..3cfaa7b2 100644 --- a/packages/live-preview-sdk/src/messages.ts +++ b/packages/live-preview-sdk/src/messages.ts @@ -5,6 +5,15 @@ import type { SysLink } from 'contentful-management'; import type { LIVE_PREVIEW_EDITOR_SOURCE, LIVE_PREVIEW_SDK_SOURCE } from './constants'; import { sendMessageToEditor } from './helpers'; +import { + InspectorModeAttributes, + InspectorModeChangedMessage, + InspectorModeEventMethods, + InspectorModeMouseMoveMessage, + InspectorModeResizeMessage, + InspectorModeScrollMessage, + InspectorModeTaggedElementsMessage, +} from './inspectorMode/types'; import type { ContentType, EntityReferenceMap } from './types'; enum LivePreviewPostMessageMethods { @@ -21,7 +30,6 @@ enum LivePreviewPostMessageMethods { ENTRY_UPDATED = 'ENTRY_UPDATED', ENTRY_SAVED = 'ENTRY_SAVED', - INSPECTOR_MODE_CHANGED = 'INSPECTOR_MODE_CHANGED', DEBUG_MODE_ENABLED = 'DEBUG_MODE_ENABLED', /** @@ -39,8 +47,13 @@ export { LivePreviewPostMessageMethods, RequestEntitiesMessage, RequestedEntitiesMessage, + InspectorModeEventMethods, }; -export type PostMessageMethods = LivePreviewPostMessageMethods | StorePostMessageMethods; + +export type PostMessageMethods = + | LivePreviewPostMessageMethods + | StorePostMessageMethods + | InspectorModeEventMethods; export type ConnectedMessage = { /** @deprecated use method instead */ @@ -57,10 +70,7 @@ export type ConnectedMessage = { export type TaggedFieldClickMessage = { /** @deprecated use method instead */ action: LivePreviewPostMessageMethods.TAGGED_FIELD_CLICKED; - fieldId: string; - entryId: string; - locale: string; -}; +} & InspectorModeAttributes; /** @deprecated use RequestEntitiesMessage instead */ export type UnknownEntityMessage = { @@ -101,7 +111,11 @@ export type EditorMessage = | UrlChangedMessage | SubscribedMessage | RequestEntitiesMessage - | ErrorMessage; + | ErrorMessage + | InspectorModeMouseMoveMessage + | InspectorModeScrollMessage + | InspectorModeResizeMessage + | InspectorModeTaggedElementsMessage; export type MessageFromSDK = EditorMessage & { method: PostMessageMethods; @@ -137,12 +151,6 @@ export type UnknownReferenceLoaded = { entityReferenceMap: EntityReferenceMap; }; -export type InspectorModeChangedMessage = { - /** @deprecated use method instead */ - action: LivePreviewPostMessageMethods.INSPECTOR_MODE_CHANGED; - isInspectorActive: boolean; -}; - export type DebugModeEnabledMessage = { /** @deprecated use method instead */ action: LivePreviewPostMessageMethods.DEBUG_MODE_ENABLED; diff --git a/packages/live-preview-sdk/src/react.tsx b/packages/live-preview-sdk/src/react.tsx index e9cf7622..8eaede20 100644 --- a/packages/live-preview-sdk/src/react.tsx +++ b/packages/live-preview-sdk/src/react.tsx @@ -17,7 +17,8 @@ import isEqual from 'lodash.isequal'; import { debounce } from './helpers'; import { ContentfulLivePreview, ContentfulLivePreviewInitConfig } from './index'; -import { Argument, InspectorModeTags, LivePreviewProps } from './types'; +import type { InspectorModeTags } from './inspectorMode/types'; +import { Argument, LivePreviewProps } from './types'; type UseEffectParams = Parameters; type EffectCallback = UseEffectParams[0]; diff --git a/packages/live-preview-sdk/src/saveEvent.ts b/packages/live-preview-sdk/src/saveEvent.ts index b1413df9..b225caf7 100644 --- a/packages/live-preview-sdk/src/saveEvent.ts +++ b/packages/live-preview-sdk/src/saveEvent.ts @@ -1,5 +1,5 @@ -import { getAllTaggedEntries } from './fieldTaggingUtils'; import { debug } from './helpers'; +import { getAllTaggedEntries } from './inspectorMode/utils'; import { EntrySavedMessage, LivePreviewPostMessageMethods, MessageFromEditor } from './messages'; import { SubscribeCallback } from './types'; diff --git a/packages/live-preview-sdk/src/styles.css b/packages/live-preview-sdk/src/styles.css index 2e9da646..ec8f8ee2 100644 --- a/packages/live-preview-sdk/src/styles.css +++ b/packages/live-preview-sdk/src/styles.css @@ -1,50 +1 @@ -[data-contentful-field-id][data-contentful-entry-id] { - outline: 1px dashed rgba(64, 160, 255, 0) !important; - transition: outline-color 0.3s ease-in-out; -} - -.contentful-inspector--active [data-contentful-field-id][data-contentful-entry-id] { - outline: 1px dashed rgba(64, 160, 255, 1) !important; -} - -.contentful-inspector--active [data-contentful-field-id][data-contentful-entry-id]:hover { - outline: 2px solid rgba(64, 160, 255, 1) !important; -} - -button.contentful-tooltip { - padding: 0; - display: none; - outline: none; - border: none; - z-index: 999999 !important; - position: fixed; - margin: 0; - height: 32px; - width: 72px; - background: rgb(3, 111, 227); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; - font-weight: 500 !important; - font-size: 14px !important; - color: #ffffff !important; - transition: background 0.2s; - text-align: center !important; - border-radius: 6px !important; - justify-content: center; - align-items: center; - box-shadow: 0px 1px 0px rgba(17, 27, 43, 0.05); - box-sizing: border-box; - cursor: pointer; - gap: 6px; -} - -button.contentful-tooltip:hover { - background: rgb(0, 89, 200); -} - -button.contentful-tooltip:active:hover { - background: rgb(0, 65, 171); -} - -.contentful-inspector--active button.contentful-tooltip { - display: flex; -} +/* TODO: remove with next breaking version */ diff --git a/packages/live-preview-sdk/src/types.ts b/packages/live-preview-sdk/src/types.ts index 8da34e43..61d17e0b 100644 --- a/packages/live-preview-sdk/src/types.ts +++ b/packages/live-preview-sdk/src/types.ts @@ -12,18 +12,6 @@ export type LivePreviewProps = { locale?: string; }; -export const enum TagAttributes { - FIELD_ID = 'data-contentful-field-id', - ENTRY_ID = 'data-contentful-entry-id', - LOCALE = 'data-contentful-locale', -} - -export type InspectorModeTags = { - [TagAttributes.ENTRY_ID]: string; - [TagAttributes.FIELD_ID]: string; - [TagAttributes.LOCALE]?: string; -} | null; - export interface SysProps { id: string; [key: string]: unknown;