Skip to content

Commit

Permalink
Merge pull request #14 from contentful/feat/tooltip-btn
Browse files Browse the repository at this point in the history
feat: add tooltip bttn [TOL-937]
  • Loading branch information
YvesRijckaert authored Mar 2, 2023
2 parents 7c21947 + c686bff commit 4031c30
Show file tree
Hide file tree
Showing 16 changed files with 636 additions and 75 deletions.
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,30 @@ npm install @contentful/live-preview

## Documentation

### Initializing the SDK

To establish a communication between your preview frontend and Contentful, you simply need to initialize the live preview SDK. This can be done by executing the following command:

```
import { ContentfulLivePreview } from '@contentful/live-preview';
...
ContentfulLivePreview.init();
```

### Field Tagging

To tag fields you need to add the live preview data-attributes to the rendered HTML element output.
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/dist/style.css' file.

```
import { getLivePreviewProps } from '@contentful/live-preview';
import { ContentfulLivePreview } from '@contentful/live-preview';
import '@contentful/live-preview/dist/style.css';
...
<h1 {...getLivePreviewProps({ entryId: id, fieldId: 'title', locale })}>
<h1 {...ContentfulLivePreview.getProps({ entryId: id, fieldId: 'title', locale })}>
{title}
</h1>
```
Expand All @@ -54,7 +67,6 @@ We want to provide a safe, inclusive, welcoming, and harassment-free space and e

The live preview package is open source software [licensed as MIT](./LICENSE).


[contentful]: https://www.contentful.com
[github-issues]: https://github.com/contentful/live-preview/issues
[typescript]: https://www.typescriptlang.org/
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-import-helpers": "^1.2.1",
"husky": "^8.0.3",
"jsdom": "^21.1.0",
"lint-staged": "^13.1.2",
"prettier": "^2.8.4",
"react": "^18.2.0",
Expand Down
7 changes: 7 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const DATA_CURR_FIELD_ID = 'current-data-contentful-field-id';
export const DATA_CURR_ENTRY_ID = 'current-data-contentful-entry-id';
export const DATA_CURR_LOCALE = 'current-data-contentful-locale';
export const TOOLTIP_CLASS = 'contentful-tooltip';

export const TOOLTIP_HEIGHT = 32;
export const TOOLTIP_PADDING_LEFT = 5;
23 changes: 0 additions & 23 deletions src/features/tagging/getLivePreviewProps.spec.ts

This file was deleted.

29 changes: 0 additions & 29 deletions src/features/tagging/getLivePreviewProps.ts

This file was deleted.

2 changes: 0 additions & 2 deletions src/features/tagging/index.tsx

This file was deleted.

124 changes: 124 additions & 0 deletions src/field-tagging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import {
DATA_CURR_ENTRY_ID,
DATA_CURR_FIELD_ID,
DATA_CURR_LOCALE,
TOOLTIP_CLASS,
TOOLTIP_HEIGHT,
TOOLTIP_PADDING_LEFT,
} from './constants';
import { TagAttributes } from './types';

export default class FieldTagging {
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

constructor() {
this.tooltip = null;
this.currentElementBesideTooltip = null;

this.resolveIncomingMessage = this.resolveIncomingMessage.bind(this);
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('message', this.resolveIncomingMessage);
window.addEventListener('scroll', this.updateTooltipPosition);
window.addEventListener('mouseover', this.addTooltipOnHover);
}

// Handles incoming messages from Contentful
private resolveIncomingMessage(e: MessageEvent) {
if (typeof e.data !== 'object') return;
if (e.data.from !== 'live-preview') return;
// Toggle the contentful-inspector--active class on the body element based on the isInspectorActive boolean
document.body.classList.toggle('contentful-inspector--active', e.data.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);

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 = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5325 2.22242C13.825 2.51492 13.825 2.98742 13.5325 3.27992L12.16 4.65242L9.3475 1.83992L10.72 0.467422C11.0125 0.174922 11.485 0.174922 11.7775 0.467422L13.5325 2.22242ZM0.25 13.7499V10.9374L8.545 2.64243L11.3575 5.45493L3.0625 13.7499H0.25Z" fill="white"/>
</svg>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() {
if (!this.tooltip) {
return;
}
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);

window.top?.postMessage(
{
from: 'live-preview',
fieldId,
entryId,
locale,
},
//todo: check if there is any security risk with this
'*'
);
}
}
34 changes: 34 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import './styles.css';
import FieldTagging from './field-tagging';
import { LivePreviewProps, TagAttributes } from './types';

export class ContentfulLivePreview {
static fieldTagging: FieldTagging | null = null;

// Static method to initialize the LivePreview SDK
static init(): Promise<FieldTagging> | undefined {
// Check if running in a browser environment
if (typeof window !== 'undefined') {
if (ContentfulLivePreview.fieldTagging) {
console.log('You have already initialized the Live Preview SDK.');
return Promise.resolve(ContentfulLivePreview.fieldTagging);
} else {
ContentfulLivePreview.fieldTagging = new FieldTagging();
return Promise.resolve(ContentfulLivePreview.fieldTagging);
}
}
}

// Static method to render live preview data-attributes to HTML element output
static getProps({
fieldId,
entryId,
locale,
}: LivePreviewProps): Record<TagAttributes, string | null | undefined> {
return {
[TagAttributes.FIELD_ID]: fieldId,
[TagAttributes.ENTRY_ID]: entryId,
[TagAttributes.LOCALE]: locale,
};
}
}
1 change: 0 additions & 1 deletion src/index.tsx

This file was deleted.

45 changes: 45 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
[data-contentful-field-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] {
outline: 1px dashed 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-weight: 500 !important;
color: #ffffff !important;
transition: background 0.2s;
text-align: center !important;
border-radius: 6px !important;
font-size: 14px !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;
}
23 changes: 23 additions & 0 deletions src/tests/getProps.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { ContentfulLivePreview } from '../index';
import { TagAttributes } from '../types';

describe('getProps', () => {
it('returns the expected props with a given entryId, fieldId and locale', () => {
const entryId = 'test-entry-id';
const fieldId = 'test-field-id';
const locale = 'test-locale';

const result = ContentfulLivePreview.getProps({
entryId,
fieldId,
locale,
});

expect(result).toStrictEqual({
[TagAttributes.FIELD_ID]: fieldId,
[TagAttributes.ENTRY_ID]: entryId,
[TagAttributes.LOCALE]: locale,
});
});
});
25 changes: 25 additions & 0 deletions src/tests/init.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { ContentfulLivePreview } from '../index';
import FieldTagging from '../field-tagging';

describe('init', () => {
it('returns a Promise that resolves to a LivePreview instance when running in a browser environment', async () => {
const livePreviewInstance = await ContentfulLivePreview.init();
expect(livePreviewInstance).toBeInstanceOf(FieldTagging);
});

it('returns undefined when not running in a browser environment', () => {
const windowBackup = global.window;
(global as any).window = undefined;
const result = ContentfulLivePreview.init();
expect(result).toBeUndefined();
global.window = windowBackup;
});

it('returns a Promise that resolves to the same LivePreview instance when called multiple times', async () => {
const livePreviewInstance1 = await ContentfulLivePreview.init();
const livePreviewInstance2 = await ContentfulLivePreview.init();
expect(livePreviewInstance1).toBe(livePreviewInstance2);
});
});
Loading

0 comments on commit 4031c30

Please sign in to comment.