├── .gitignore
├── README.md
├── Roadmap.md
├── example-blog.jpg
├── example.jpg
├── integration.jpg
├── package-lock.json
├── package.json
├── src
├── attach.ts
├── fetch.ts
├── index.ts
├── init.ts
├── popup
│ ├── fetch.ts
│ ├── index.ts
│ ├── renderAssets.ts
│ ├── renderContentTypes.ts
│ ├── renderEntries.ts
│ └── renderEntriesByCT.ts
├── state.ts
├── types.ts
└── utils
│ ├── animate.ts
│ ├── dom.ts
│ ├── events.ts
│ ├── index.ts
│ ├── links.ts
│ └── style.ts
├── tsconfig.commonjs.json
├── tsconfig.json
├── tslint.json
└── webpack.config.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | lib/
4 | dist/
5 | /.idea/
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Contentful-wizard
2 |
3 | [](https://badge.fury.io/js/contentful-wizard)
4 | [](https://github.com/prettier/prettier)
5 |
6 | This is a library to add an interactive content explorer to your [contentful-powered project](https://www.contentful.com/) – it allows you to mark parts of your application, and this library will highlight marked block, add automatic tooltips on hover with links to contentful application.
7 |
8 | It allows your editors quickly understand which element on the page is responsible for specific entry and content type (or an asset, if you want to mark it to this details).
9 |
10 | > The example app: [live demo](https://contentful-wizard-tea.bloomca.me) | [integration commit](https://github.com/Bloomca/the-example-app.nodejs/commit/f4932887b6a1cc7a91072e54589a81c48e06d1f1)
11 |
12 |
13 |
14 | > Blog example: [live demo](https://dist-dohdcjzdoz.now.sh/) | [integration commit](https://github.com/Bloomca/blog-in-5-minutes/commit/2419bcf23e54e59ec130728432b986078bc11b88)
15 |
16 | 
17 | 
18 |
19 | This is only client-side library, it assumes DOM is available.
20 | Right now it is in pre-alpha stage, but you can take a look [at the roadmap](./Roadmap.md) to get a feeling what is going to be implemented.
21 |
22 | > This library is in alpha stage – it is a proof of concept with (likely) a lot of bugs. Feedback is welcome!
23 |
24 | ## Contribution
25 |
26 | This project is part of [contentful-userland](https://github.com/contentful-userland) which means that we’re always open to contributions and pull requests. You can learn more about how contentful userland is organized by visiting [our about repository](https://github.com/contentful-userland/about).
27 |
28 | ## Getting started
29 |
30 | First, you need to include this library into your application – you can just include [umd build](https://unpkg.com/contentful-wizard@0.0.1-alpha-6/dist/contentful-wizard.min.js). It will add a global object, which you can call to instantiate a wizard – this will iterate over all DOM elements, picking those with `data-ctfl-entry` and `data-ctfl-asset` data attributes, highlight borders of these elements and add tooltips on hover with all links and info about other elements on the page.
31 |
32 | > You can customize a lot of things, but this is the bare minimum, which might be enough
33 |
34 | ```html
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
51 | ```
52 |
53 | ## Entry titles
54 |
55 | In the tooltip's content we show:
56 | - content types on the page
57 | - entries on the page
58 | - assets on the page
59 |
60 | Content types and assets have special properties (`name` for content types and `fields.title` for assets), but entries have only specified fields, so there is no way to generalize it. In order to address it, and to list entries in human-readable form, you can pass an `entryTitle` property into `CTFLWizard.init` method configuration object. There are several strategies supported:
61 |
62 | ```js
63 | CTFLWizard.init({
64 | spaceId: 's25qxvg',
65 | key: 'f78aw812mlswwasw', // your API key
66 | // if all your entries should show this field's value
67 | entryTitle: 'myTitle',
68 |
69 | // or, if you need to display different fields
70 | // for different content types
71 | entryTitle: {
72 | [contenTypeId]: 'mySpecialTitle'
73 | }
74 | });
75 | ```
76 |
77 | If you don't provide any (or fields by your strategy don't exist or don't have a value), the library will try to get `title` field first, and then `name`. However, it is still pretty magical, so it is better to provide your own field names.
78 |
79 | > There is no "smart" guessing strategy (like trying to list all fields with string value and get one with a short enough value), since it can easily introduce incosistency, and it will become confusing.
80 |
81 |
82 | ## Initialization
83 |
84 | > In default configuration there is pre-configured host for you published entries and assets.
85 | > If you need to work with preview items define a custom host as `init` parameter.
86 |
87 | ```js
88 | CTFLWizard.init({
89 | spaceId: 's25qxvg',
90 | key: 'f78aw812mlswwasw', // your API key
91 | host: 'preview.contentful.com' // preview host
92 | });
93 | ```
94 |
95 |
96 | ## Styling
97 |
98 | Your use-case might be different – for example, you need wide tooltips, or default colours don't match your schema; you can customize all default styles, except positioning (`top`, `left`, `right`, `bottom`), it is calculated automatically and adjusted to the content. You provide your own styling during calling `CTFLWizard.init`, and you can omit any of these properties, and the whole property is optional – below you can find a commented breakdown on all style options:
99 |
100 | > Since it is in pre-alpha (read: super-early) and styles are subject to change, I don't want to share styles to avoid frequent updates. Once it stabilizies, all default styles will be shown in this snippet
101 |
102 | ```js
103 | CTFLWizard.init({
104 | spaceId: 's25qxvg',
105 | key: 'f78aw812mlswwasw', // your API key
106 |
107 | // the whole property is optional
108 | style: {
109 | // style of the container to indicate
110 | // that it has hover capabilities
111 | highlight: {
112 | border: '2px dashed #ccc'
113 | },
114 | // style during the hover
115 | highlightHover: {
116 | border: '2px solid #ccc'
117 | },
118 | // styles of the tooltip itself
119 | tooltip: {
120 | background: '#ccc'
121 | },
122 | // overlay – when we hover types in the tooltip
123 | // we add overlay to corresponding elements
124 | overlay: {
125 | background: 'green'
126 | }
127 | }
128 | });
129 | ```
130 |
131 | ## Environments
132 | Contentful-wizard now supports [environments](https://www.contentful.com/developers/docs/concepts/multiple-environments/)
133 | It is optional feature with default 'master' value
134 | ```
135 | CTFLWizard.init({
136 | spaceId: 's25qxvg',
137 | key: 'f78aw812mlswwasw', // your API key
138 | environment: 'development' //environment name
139 | })
140 | ```
141 |
142 | ## Update Tooltips
143 |
144 | Create wizard instance initializes the library, attaching listeners to all elements with corresponding data-attributes to show tooltip on hovering. However, sometimes you change content on your page, and you would like to add tooltips for new content. In order to do that, you can invoke `.update` method on returned instance:
145 |
146 | > You can provide an arg for `update` method – it is an object with the same parameters as for `init`, so you can override any parameters you had set up before
147 |
148 | ```js
149 | const wizard = CTFLWizard.init({
150 | spaceId: 's25qxvg',
151 | key: 'f78aw812mlswwasw'
152 | });
153 |
154 | // after some changes
155 | wizard.update({
156 | // new entry title field resolution
157 | entryTitle: 'newTitle'
158 | });
159 | ```
160 |
161 | Another use case is to remove all listeners completely – for example, you re-rendered everything, or contentful content became irrelevant. In order to do that, you can call `.destroy` method:
162 |
163 | ```js
164 | const wizard = CTFLWizard.init({
165 | spaceId: 's25qxvg',
166 | key: 'f78aw812mlswwasw'
167 | });
168 |
169 | // if you want to remove all contentful-wizard functionality
170 | wizard.destroy();
171 | ```
172 |
173 | ## Single Page Applications
174 |
175 | In case you have rich web application, where content can change very often, and the whole page updates don't make a lot of sense, since they happen too often, there is a way to attach/deattach them manually:
176 |
177 | ```js
178 | import { attach, init } from 'contentful-wizard';
179 |
180 | init({
181 | spaceId: 'your_space',
182 | key: 'CDA_key'
183 | });
184 |
185 | const cancel = attach({
186 | node: document.getElementById('myEntry'),
187 | spaceId: 'your_space',
188 |
189 | // in case you have an entry
190 | contentType: '',
191 | entry: '',
192 | // optional, by default 'title' and 'name' will be tried
193 | entryTitle: 'myTitleField',
194 |
195 | // in case you have an asset
196 | asset: '',
197 |
198 | // in case you want to provide your content on top
199 | // of the tooltip
200 | description: 'This is our main story',
201 |
202 | // If you want to override styles for this specific
203 | // node. They will be merged with passed in `init` fn
204 | // for structure see https://github.com/Bloomca/contentful-wizard#styling
205 | style: { ... }
206 | });
207 | ```
208 |
209 | In practice, using modern frameworks, it makes sense to add them in lifecycle hooks. I'll show it using [react lifecycle hooks](https://reactjs.org/docs/state-and-lifecycle.html#adding-lifecycle-methods-to-a-class) as an example, but it works similarly across all popular frameworks.
210 |
211 | ```js
212 | // init during the bootstrap of your application
213 | import { init } from 'contentful-wizard';
214 |
215 | // this is needed to save your key
216 | init({
217 | spaceId: 'your_space',
218 | key: 'CDA_key'
219 | });
220 |
221 | // wizard component to wrap contentful blocks
222 | import React, { Component } from 'react';
223 | import RPT from 'prop-types';
224 | import { attach } from 'contentful-wizard';
225 |
226 | export default class CTFLWizard extends Component {
227 | static propTypes = {
228 | children: RPT.node,
229 | entity: RPT.object,
230 | description: RPT.string
231 | }
232 |
233 | componentDidMount() {
234 | const { entity, description } = this.props;
235 | const isEntry = entity.sys.type === 'Entry';
236 |
237 | // mount tooltip on our container
238 | this.cleanup = attach({
239 | node: this.node,
240 | // decide which type of contentful entity we have
241 | entry: isEntry ? entity.sys.id : undefined,
242 | contentType: isEntry ? entity.sys.contentType.sys.id : undefined,
243 | asset: isEntry ? undefined : entity.sys.id,
244 | spaceId: entity.sys.space.sys.id,
245 | // description for the tooltip
246 | description
247 | });
248 | }
249 |
250 | componentWillMount() {
251 | this.cleanup && this.cleanup();
252 | }
253 |
254 | render() {
255 | const { children } = this.props;
256 |
257 | return (
258 | this.node => node}>
259 | {children}
260 |
261 | );
262 | }
263 | }
264 |
265 | // later, in another component:
266 | import Wizard from '../components/wizard';
267 |
268 | // ...
269 | const markup = (
270 |
271 | {course.fields.description}
272 |
273 | );
274 | ```
275 |
276 | As you can see, you just have to initialize to save CDA key, and then write one component, which will attach and cleanup tooltips based on the lifecycle hooks.
277 |
278 | ## Roles and Permissions
279 |
280 | Contentful can have pretty intrinsic set of rules and permissions, and it is possible that all presented options can be confusing, since some roles don't have access to some parts of the application (for example, to content types). Unfortunately, right now there is no way to differentiate between roles – we are using CDA (or CPA) key, which is valid to get content of the whole space.
281 |
282 | However, there are couple of things in the roadmap:
283 | - configuration of the tooltip (so you can configure it to omit some parts of the tooltip)
284 | - control panel to show only some parts
285 |
286 | Please, create an issue in case you feel this is important (and if you have another idea, you are welcome to share it!).
287 |
288 | ## Contributing
289 |
290 | All contributions are more than welcome! If you want to add some new feature, I'd kindly ask you to create an issue first, so we can discuss the solution first – since the goal of this library is pretty abstract, it makes sense to discuss problem first, and only afterwards implement it, right?
291 |
292 | You can take a look [at the Roadmap](./Roadmap.md), in order to feel what is going to be implemented. If you feel something does not make sense, or can be done better, please submit an issue as well!
293 |
294 | ## License
295 |
296 | MIT
297 |
--------------------------------------------------------------------------------
/Roadmap.md:
--------------------------------------------------------------------------------
1 | # Roadmap
2 |
3 | This project is in a pre-alpha version, so this roadmap is just a list of points, which would be nice – don't treat it as written in stone points.
4 | There are no explicit priorities here, and some items can be thrown away, and some can be added later.
5 |
6 | - add configuration, so you can choose what (and in which order) to show in the tooltip
7 | - add control panel, which will allow to configure content on the fly
8 | - add possibility to style:
9 | - specific styles for content-types
10 | - improve tooltip:
11 | - add "nose", so it looks better
12 | - list of CTs/entries/assets:
13 | - fold more than 5 elements
14 | - add locale support
15 |
16 | - add field link to the popup
17 |
18 | ## Done
19 |
20 | - ~~add description data-attribute~~
21 | - ~~add querying against preview option~~
22 | - ~~add assets rendering~~
23 |
24 | - add possibility to style:
25 | - ~~border/overlay for elements with data-attributes~~
26 |
27 | - improve tooltip:
28 | - ~~automatic rearrangement according to the screen's position~~
29 | - ~~automatically detect at which side it is better to render tooltip~~
30 |
--------------------------------------------------------------------------------
/example-blog.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/contentful-userland/contentful-wizard/f9b8db590908a2fc3060e7b05c501840bfb3592b/example-blog.jpg
--------------------------------------------------------------------------------
/example.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/contentful-userland/contentful-wizard/f9b8db590908a2fc3060e7b05c501840bfb3592b/example.jpg
--------------------------------------------------------------------------------
/integration.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/contentful-userland/contentful-wizard/f9b8db590908a2fc3060e7b05c501840bfb3592b/integration.jpg
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "contentful-wizard",
3 | "version": "0.0.4",
4 | "description": "This is a library to add interactive content explorer to your contentful-powered project.",
5 | "jsnext:main": "lib/es2015/index.js",
6 | "module": "lib/es2015/index.js",
7 | "typings": "lib/es2015/index.d.ts",
8 | "main": "lib/commonjs/index.js",
9 | "files": [
10 | "lib",
11 | "dist",
12 | "src",
13 | "README.md",
14 | "package.json"
15 | ],
16 | "scripts": {
17 | "clean": "rimraf lib dist coverage",
18 | "precommit": "lint-staged",
19 | "prepublish": "npm run clean && npm run test && npm run build",
20 | "build:es2015": "tsc -P tsconfig.json",
21 | "build:es2015:watch": "tsc --watch -P tsconfig.json",
22 | "build:commonjs": "tsc -P tsconfig.commonjs.json",
23 | "build:umd": "cross-env NODE_ENV=development webpack",
24 | "build:umd:min": "cross-env NODE_ENV=production webpack",
25 | "build:dist": "npm run build:umd && npm run build:umd:min",
26 | "build": "npm run build:es2015 && npm run build:commonjs && npm run build:dist",
27 | "lint": "tslint src/**/*.ts",
28 | "test": "npm run lint"
29 | },
30 | "lint-staged": {
31 | "*.ts": [
32 | "prettier --write",
33 | "git add"
34 | ]
35 | },
36 | "repository": {
37 | "type": "git",
38 | "url": "git+https://github.com/Bloomca/contentful-wizard.git"
39 | },
40 | "keywords": [
41 | "contentful",
42 | "wizard",
43 | "explore"
44 | ],
45 | "author": "Seva Zaikov ",
46 | "license": "MIT",
47 | "bugs": {
48 | "url": "https://github.com/Bloomca/contentful-wizard/issues"
49 | },
50 | "homepage": "https://github.com/Bloomca/contentful-wizard#readme",
51 | "dependencies": {
52 | "webpack": "^3.10.0",
53 | "contentful": "^8.3.7"
54 | },
55 | "devDependencies": {
56 | "babel-core": "^6.26.0",
57 | "babel-preset-es2015-rollup": "^3.0.0",
58 | "cross-env": "^5.1.1",
59 | "husky": "^0.14.3",
60 | "lint-staged": "^6.0.0",
61 | "prettier": "1.9.1",
62 | "rimraf": "^2.6.2",
63 | "tslint": "^5.8.0",
64 | "tslint-config-prettier": "^1.6.0",
65 | "typescript": "^2.6.2",
66 | "uglifyjs-webpack-plugin": "^1.1.4"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/attach.ts:
--------------------------------------------------------------------------------
1 | import { showPopup } from "./popup";
2 | import {
3 | getStyle,
4 | removeAssetNode,
5 | removeContentTypeNode,
6 | removeEntryNode,
7 | setAssetNode,
8 | setContentTypeNode,
9 | setEntryNode
10 | } from "./state";
11 | import { IEntryTitle, IStyles } from "./types";
12 | import {
13 | applyStyle,
14 | containsNode,
15 | isBrowser,
16 | mergeStyle,
17 | onHover
18 | } from "./utils";
19 |
20 | export interface IAttachConfig {
21 | node: HTMLElement;
22 | contentType: string | null;
23 | entry: string | null;
24 | spaceId: string;
25 | asset: string | null;
26 | entryTitle?: IEntryTitle;
27 | description: string | null;
28 | style: IStyles;
29 | }
30 |
31 | export function attach({
32 | node,
33 | contentType,
34 | entry,
35 | spaceId,
36 | entryTitle,
37 | description,
38 | style: rawStyle = getStyle({ spaceId }),
39 | asset
40 | }: IAttachConfig) {
41 | if (!isBrowser()) {
42 | // tslint:disable-next-line no-empty
43 | return () => {};
44 | }
45 |
46 | if (contentType) {
47 | setContentTypeNode({ contentType, node });
48 | }
49 |
50 | if (contentType && entry) {
51 | setEntryNode({
52 | contentType,
53 | entry,
54 | node
55 | });
56 | }
57 |
58 | if (asset) {
59 | setAssetNode({
60 | asset,
61 | node
62 | });
63 | }
64 |
65 | const style =
66 | rawStyle === getStyle({ spaceId }) ? rawStyle : mergeStyle(rawStyle);
67 |
68 | let destroyPopup: Function | null;
69 | let popupNode: HTMLElement;
70 |
71 | applyStyle({
72 | node,
73 | style: style.highlight
74 | });
75 | // 1. add style to indicate that you can hover
76 | // 2. show tooltip on hover
77 | const cleanHover = onHover({
78 | node,
79 | onMouseEnter,
80 | onMouseLeave
81 | });
82 |
83 | function onMouseEnter() {
84 | applyStyle({
85 | node,
86 | style: style.highlightHover
87 | });
88 | destroyPopup && destroyPopup();
89 | const popupData = showPopup({
90 | node,
91 | spaceId,
92 | entry,
93 | contentType,
94 | asset,
95 | cleanup: internalMouseLeave,
96 | entryTitle,
97 | description,
98 | style
99 | });
100 | destroyPopup = popupData.destroy;
101 | popupNode = popupData.node;
102 | }
103 |
104 | function onMouseLeave(e: MouseEvent) {
105 | const checkingNode = e.relatedTarget as HTMLElement;
106 |
107 | if (!containsNode({ node: popupNode, checkingNode })) {
108 | internalMouseLeave();
109 | }
110 | }
111 |
112 | return cleanup;
113 |
114 | function internalMouseLeave() {
115 | destroyPopup && destroyPopup();
116 | destroyPopup = null;
117 | applyStyle({
118 | node,
119 | style: style.highlight
120 | });
121 | }
122 |
123 | function cleanup() {
124 | if (contentType) {
125 | removeContentTypeNode({ contentType, node });
126 | }
127 |
128 | if (contentType && entry) {
129 | removeEntryNode({ contentType, node, entry });
130 | }
131 |
132 | if (asset) {
133 | removeAssetNode({ asset, node });
134 | }
135 | destroyPopup && destroyPopup();
136 | cleanHover && cleanHover();
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/src/fetch.ts:
--------------------------------------------------------------------------------
1 | import { ContentfulClientApi } from "contentful";
2 |
3 | export interface ISysEntity {
4 | sys: {
5 | id: string;
6 | };
7 | }
8 |
9 | export interface IEntity {
10 | sys: {
11 | id: string;
12 | environment?: ISysEntity;
13 | };
14 | name?: string;
15 | }
16 |
17 | const contentTypesData: { [key: string]: IEntity } = {};
18 | const entriesData: { [key: string]: IEntity } = {};
19 | const assetsData: { [key: string]: IEntity } = {};
20 |
21 | let contentTypesPromise: Promise;
22 | let entriesPromise: Promise;
23 | let assetsPromise: Promise;
24 |
25 | export function fetchAssets({ client }: { client: ContentfulClientApi }) {
26 | if (!assetsPromise) {
27 | assetsPromise = client
28 | .getAssets({
29 | limit: 1000
30 | })
31 | .then(({ items }) => {
32 | items.forEach((item: IEntity) => {
33 | assetsData[item.sys.id] = item;
34 | });
35 | })
36 | .then();
37 | }
38 |
39 | return assetsPromise;
40 | }
41 |
42 | export function fetch({
43 | client,
44 | contentType,
45 | entry,
46 | asset
47 | }: {
48 | client: ContentfulClientApi;
49 | contentType: string | null;
50 | entry: string | null;
51 | asset: string | null;
52 | }) {
53 | if (!contentTypesPromise) {
54 | contentTypesPromise = client
55 | .getContentTypes({
56 | // maximum – we should try to download everything
57 | limit: 1000
58 | })
59 | .then(({ items }: { items: IEntity[] }) => {
60 | // ignore that we can skip some data
61 | items.forEach((item: IEntity) => {
62 | contentTypesData[item.sys.id] = item;
63 | });
64 | });
65 | }
66 |
67 | if (!entriesPromise) {
68 | entriesPromise = client
69 | .getEntries({
70 | // maximum – we should try to download everything
71 | limit: 1000
72 | })
73 | .then(({ items }: { items: IEntity[] }) => {
74 | // ignore that we can skip some data
75 | items.forEach(item => {
76 | entriesData[item.sys.id] = item;
77 | });
78 | });
79 | }
80 |
81 | if (!assetsPromise) {
82 | assetsPromise = client
83 | .getAssets({
84 | limit: 1000
85 | })
86 | .then(({ items }) => {
87 | items.forEach((item: IEntity) => {
88 | assetsData[item.sys.id] = item;
89 | });
90 | });
91 | }
92 |
93 | return Promise.all([contentTypesPromise, entriesPromise, assetsPromise])
94 | .then(() => {
95 | if (contentType) {
96 | // check content type
97 | const contentTypeFetchedData = contentTypesData[contentType];
98 |
99 | if (!contentTypeFetchedData) {
100 | return client.getContentType(contentType).then(item => {
101 | contentTypesData[item.sys.id] = item;
102 | });
103 | }
104 | }
105 | })
106 | .then(() => {
107 | if (entry) {
108 | // check entry
109 | const entryFetchedData = entriesData[entry];
110 |
111 | if (!entryFetchedData) {
112 | return client.getEntry(entry).then(item => {
113 | entriesData[item.sys.id] = item;
114 | });
115 | }
116 | }
117 | })
118 | .then(() => {
119 | if (asset) {
120 | const assetFetchedData = assetsData[asset];
121 |
122 | if (!assetFetchedData) {
123 | return client.getAsset(asset).then(item => {
124 | assetsData[item.sys.id] = item;
125 | });
126 | }
127 | }
128 | })
129 | .then(() => ({ contentTypesData, entriesData, assetsData }));
130 | }
131 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { attach } from "./attach";
2 | import { init } from "./init";
3 |
4 | export { init, attach };
5 |
--------------------------------------------------------------------------------
/src/init.ts:
--------------------------------------------------------------------------------
1 | import { ContentfulClientApi, createClient } from "contentful";
2 | import { attach, IAttachConfig } from "./attach";
3 | import { setStyle } from "./state";
4 | import { IEntryTitle, IStyles } from "./types";
5 | import { isBrowser, mergeStyle } from "./utils";
6 |
7 | export const clients: { [key: string]: ContentfulClientApi } = {};
8 |
9 | export function init({
10 | key,
11 | spaceId,
12 | host,
13 | basePath,
14 | environment,
15 | entryTitle,
16 | style
17 | }: {
18 | key: string;
19 | spaceId: string;
20 | host?: string;
21 | environment?: string;
22 | basePath?: string;
23 | entryTitle?: IEntryTitle;
24 | style?: IStyles;
25 | }) {
26 | if (!isBrowser()) {
27 | return {
28 | // tslint:disable-next-line no-empty
29 | update: () => {},
30 | // tslint:disable-next-line no-empty
31 | destroy: () => {}
32 | };
33 | }
34 |
35 | clients[spaceId] = createClient({
36 | // This is the space ID. A space is like a project folder in Contentful terms
37 | space: spaceId,
38 | // This is the access token for this space. Normally you get both ID and the token in the Contentful web app
39 | accessToken: key,
40 | // no additional requests
41 | resolveLinks: false,
42 | environment: environment ? environment : "master",
43 | basePath,
44 | host: host ? host : "cdn.contentful.com"
45 | });
46 |
47 | const mergedStyle = mergeStyle(style);
48 |
49 | setStyle({ spaceId, style: mergedStyle });
50 |
51 | let cleanup: Function | null = attachHandlers({
52 | spaceId,
53 | entryTitle,
54 | style: mergedStyle
55 | });
56 |
57 | return {
58 | // cleanup old tooltips and reattach everything once again
59 | update: () => {
60 | cleanup && cleanup();
61 | cleanup = attachHandlers({
62 | spaceId,
63 | entryTitle,
64 | style: mergedStyle
65 | });
66 | },
67 | destroy: () => {
68 | cleanup && cleanup();
69 | cleanup = null;
70 | }
71 | };
72 | }
73 |
74 | function attachHandlers({
75 | spaceId,
76 | entryTitle,
77 | style
78 | }: {
79 | spaceId: string;
80 | entryTitle?: IEntryTitle;
81 | style: IStyles;
82 | }) {
83 | const entryElements = document.querySelectorAll("[data-ctfl-entry]");
84 | const assetElements = document.querySelectorAll("[data-ctfl-asset]");
85 |
86 | const allElements = [
87 | ...Array.from(entryElements),
88 | ...Array.from(assetElements)
89 | ];
90 |
91 | const cleanupFns: Function[] = [];
92 |
93 | Array.prototype.forEach.call(allElements, (el: HTMLElement) => {
94 | const contentType = el.getAttribute("data-ctfl-content-type");
95 | const entry = el.getAttribute("data-ctfl-entry");
96 | const description = el.getAttribute("data-ctfl-description");
97 | const asset = el.getAttribute("data-ctfl-asset");
98 | const params: IAttachConfig = {
99 | entryTitle,
100 | node: el,
101 | spaceId,
102 | contentType,
103 | entry,
104 | description,
105 | style,
106 | asset
107 | };
108 | const cleanup = attach(params);
109 |
110 | cleanupFns.push(cleanup);
111 | });
112 |
113 | return () => cleanupFns.forEach(fn => fn());
114 | }
115 |
--------------------------------------------------------------------------------
/src/popup/fetch.ts:
--------------------------------------------------------------------------------
1 | import { fetch } from "../fetch";
2 | import { clients } from "../init";
3 | import { IEntryTitle, IStyles } from "../types";
4 | import { constructAssetURL, createElement } from "../utils";
5 | import {
6 | constructContentTypeURL,
7 | constructEntryURL,
8 | constructSpaceURL
9 | } from "../utils";
10 | import { renderAssets } from "./renderAssets";
11 | import { renderContentTypes } from "./renderContentTypes";
12 | import { renderEntriesByCt } from "./renderEntriesByCt";
13 |
14 | export function fetchContent({
15 | spaceId,
16 | contentType,
17 | entry,
18 | asset,
19 | entryTitle,
20 | description,
21 | style
22 | }: {
23 | spaceId: string;
24 | contentType: string | null;
25 | entry: string | null;
26 | asset: string | null;
27 | entryTitle?: IEntryTitle;
28 | description: string | null;
29 | style: IStyles;
30 | }) {
31 | let closed = false;
32 | const client = clients[spaceId];
33 | const cleanupFns: Function[] = [];
34 | const promise = fetch({ client, contentType, entry, asset }).then(
35 | ({ contentTypesData, entriesData, assetsData }) => {
36 | if (!closed) {
37 | const container = document.createElement("div");
38 | const { node: ctsContainer, cleanup: ctsCleanup } = renderContentTypes({
39 | contentType,
40 | contentTypesData,
41 | spaceId,
42 | style
43 | });
44 | const {
45 | node: entriesContainer,
46 | cleanup: entriesCleanup
47 | } = renderEntriesByCt({
48 | contentType,
49 | contentTypesData,
50 | entriesData,
51 | entryTitle,
52 | spaceId,
53 | style
54 | });
55 |
56 | const { node: assetsContainer, cleanup: assetsCleanup } = renderAssets({
57 | assetsData,
58 | spaceId,
59 | style,
60 | asset
61 | });
62 | cleanupFns.push(ctsCleanup, entriesCleanup, assetsCleanup);
63 |
64 | const spaceURL = constructSpaceURL({ spaceId });
65 | const descriptionNode = createElement({
66 | text: description
67 | });
68 |
69 | const spaceLink = renderLink({ href: spaceURL, text: "Link to space" });
70 |
71 | container.appendChild(descriptionNode);
72 | container.appendChild(spaceLink);
73 |
74 | const contentTypeData = contentType && contentTypesData[contentType];
75 | const contentTypeEnvironment = contentTypeData
76 | ? contentTypeData.sys.environment &&
77 | contentTypeData.sys.environment.sys.id
78 | : undefined;
79 |
80 | if (contentType) {
81 | const contentTypeURL = constructContentTypeURL({
82 | contentType,
83 | spaceId,
84 | environment: contentTypeEnvironment
85 | });
86 | const ctLink = renderLink({
87 | href: contentTypeURL,
88 | text: "Link to content type"
89 | });
90 | container.appendChild(ctLink);
91 | }
92 |
93 | const entryData = entry && entriesData[entry];
94 | const entryEnvironment = entryData
95 | ? entryData.sys.environment && entryData.sys.environment.sys.id
96 | : undefined;
97 |
98 | if (entry) {
99 | const entryURL = constructEntryURL({
100 | spaceId,
101 | entry,
102 | environment: entryEnvironment
103 | });
104 | const entryLink = renderLink({
105 | href: entryURL,
106 | text: "Link to entry"
107 | });
108 | container.appendChild(entryLink);
109 | }
110 |
111 | if (asset) {
112 | const assetURL = constructAssetURL({ spaceId, asset });
113 | const assetLink = renderLink({
114 | href: assetURL,
115 | text: "Link to asset"
116 | });
117 | container.appendChild(assetLink);
118 | }
119 |
120 | container.appendChild(ctsContainer);
121 | container.appendChild(entriesContainer);
122 | container.appendChild(assetsContainer);
123 |
124 | return container;
125 | }
126 | }
127 | );
128 |
129 | return {
130 | promise,
131 | cleanup: () => {
132 | closed = true;
133 | cleanupFns.forEach(fn => fn());
134 | }
135 | };
136 | }
137 |
138 | function renderLink({ text, href }: { text: string; href: string }) {
139 | return createElement({
140 | tag: "a",
141 | attrs: {
142 | href,
143 | target: "_blank"
144 | },
145 | text,
146 | style: {
147 | textDecoration: "underline",
148 | color: "blue",
149 | display: "block"
150 | }
151 | });
152 | }
153 |
--------------------------------------------------------------------------------
/src/popup/index.ts:
--------------------------------------------------------------------------------
1 | import { IEntryTitle, IStyles } from "../types";
2 | import {
3 | animate,
4 | applyStyle,
5 | containsNode,
6 | createElement,
7 | measureHeight,
8 | onHover
9 | } from "../utils";
10 | import { fetchContent } from "./fetch";
11 |
12 | export function showPopup({
13 | node,
14 | spaceId,
15 | contentType,
16 | entry,
17 | cleanup,
18 | asset,
19 | entryTitle,
20 | description,
21 | style
22 | }: {
23 | node: HTMLElement;
24 | spaceId: string;
25 | environment?: string;
26 | contentType: string | null;
27 | entry: string | null;
28 | asset: string | null;
29 | cleanup: Function;
30 | entryTitle?: IEntryTitle;
31 | description: string | null;
32 | style: IStyles;
33 | }) {
34 | const loadingContent = `
35 |
36 | ${description || ""}
37 |
38 | loading...`;
39 | const positionStyles = getCoords({ node, content: loadingContent });
40 | const tooltip: HTMLElement = createElement({
41 | style: Object.assign({}, style.tooltip, positionStyles),
42 | text: loadingContent
43 | });
44 |
45 | const { promise, cleanup: cleanupContent } = fetchContent({
46 | spaceId,
47 | contentType,
48 | entry,
49 | asset,
50 | entryTitle,
51 | description,
52 | style
53 | });
54 |
55 | promise.then(content => {
56 | if (content) {
57 | tooltip.innerHTML = "";
58 | const newPositionStyles = getCoords({ node, content });
59 | applyStyle({
60 | node: tooltip,
61 | style: newPositionStyles
62 | });
63 | // we need to calculate height somehow
64 | tooltip.appendChild(content);
65 | }
66 | });
67 |
68 | document.body.appendChild(tooltip);
69 |
70 | animate({
71 | node: tooltip,
72 | start: 0,
73 | stop: 1
74 | });
75 |
76 | const cleanupHover = onHover({
77 | node: tooltip,
78 | onMouseLeave: (e: MouseEvent) => {
79 | const checkingNode = e.relatedTarget as HTMLElement;
80 | if (!containsNode({ node, checkingNode })) {
81 | cleanup();
82 | }
83 | }
84 | });
85 |
86 | return {
87 | node: tooltip,
88 | destroy: async () => {
89 | try {
90 | await animate({
91 | node: tooltip,
92 | start: 1,
93 | stop: 0
94 | });
95 | cleanupContent();
96 | cleanupHover();
97 | document.body.removeChild(tooltip);
98 | } catch (e) {
99 | // tslint:disable-next-line no-console
100 | console.log("error during removing tooltip::", e);
101 | }
102 | }
103 | };
104 | }
105 |
106 | function getCoords({
107 | node,
108 | content
109 | }: {
110 | node: HTMLElement;
111 | content: HTMLElement | string;
112 | }) {
113 | const horizontalStyles = positionHorizontally({ node });
114 | const verticalStyles = positionVertically({ node, content });
115 | return Object.assign({}, horizontalStyles, verticalStyles);
116 | }
117 |
118 | function positionHorizontally({ node }: { node: HTMLElement }) {
119 | const { right, left } = node.getBoundingClientRect();
120 | const offsetX = window.pageXOffset;
121 |
122 | const pageWidth = document.documentElement.clientWidth;
123 |
124 | const rightSpace = pageWidth - right;
125 | const leftSpace = left;
126 |
127 | const positionStyles: { [key: string]: string } = {};
128 |
129 | // by default we try to show it from the right side
130 | if (rightSpace > 350) {
131 | positionStyles.left = `${offsetX + right - 5}px`;
132 | positionStyles.right = "auto";
133 | } else if (leftSpace > 350) {
134 | positionStyles.right = `${offsetX + pageWidth - left - 5}px`;
135 | positionStyles.left = "auto";
136 | } else {
137 | // we have to render it inside, and we do it on the right side
138 | positionStyles.right = `${offsetX + rightSpace + 5}px`;
139 | positionStyles.left = "auto";
140 | }
141 |
142 | return positionStyles;
143 | }
144 |
145 | function positionVertically({
146 | node,
147 | content
148 | }: {
149 | node: HTMLElement;
150 | content: HTMLElement | string;
151 | }) {
152 | const { top, height } = node.getBoundingClientRect();
153 | const offsetY = window.pageYOffset;
154 | const pageHeight = document.documentElement.clientHeight;
155 | const mediumOffset = top + height / 2;
156 |
157 | const maxSpace = Math.min(mediumOffset, pageHeight - mediumOffset) * 2;
158 | // consider padding of the tooltip itself. In case of
159 | // overriding styles it will become incorrect
160 | // TODO consider padding property
161 | const contentHeight = measureHeight(content) + 30;
162 |
163 | const positionStyles: { [key: string]: string } = {};
164 | if (contentHeight >= pageHeight) {
165 | positionStyles.top = `${offsetY}px`;
166 | positionStyles.bottom = `auto`;
167 | } else if (contentHeight <= maxSpace) {
168 | positionStyles.top = `${offsetY + mediumOffset - contentHeight / 2}px`;
169 | positionStyles.bottom = `auto`;
170 | } else {
171 | const topOffset =
172 | mediumOffset < pageHeight - mediumOffset
173 | ? offsetY
174 | : offsetY + pageHeight - contentHeight;
175 |
176 | positionStyles.top = `${topOffset}px`;
177 | positionStyles.bottom = `auto`;
178 | }
179 |
180 | return positionStyles;
181 | }
182 |
--------------------------------------------------------------------------------
/src/popup/renderAssets.ts:
--------------------------------------------------------------------------------
1 | import { getAssetsNodes } from "../state";
2 | import { IStyles } from "../types";
3 | import {
4 | constructAssetURL,
5 | createElement,
6 | onHover,
7 | renderOverlay
8 | } from "../utils";
9 |
10 | export function renderAssets({
11 | assetsData,
12 | spaceId,
13 | style,
14 | asset
15 | }: {
16 | assetsData: { [key: string]: any };
17 | spaceId: string;
18 | style: IStyles;
19 | environment?: string;
20 | asset: string | null;
21 | }) {
22 | const assetsContainer = document.createElement("div");
23 | const line = createElement({
24 | style: {
25 | height: "1px",
26 | margin: "10px 0",
27 | background: "#ccc"
28 | }
29 | });
30 | const header = createElement({
31 | tag: "h3",
32 | text: "Assets on the page:",
33 | style: {
34 | lineHeight: "1.31",
35 | fontSize: "1.17em",
36 | marginTop: "0",
37 | marginBottom: "10px"
38 | }
39 | });
40 |
41 | const cleanupFns: Function[] = [];
42 | const assetNodes = getAssetsNodes();
43 | const filteredAssets = Object.keys(assetNodes).filter(
44 | assetAtPage => assetAtPage !== asset
45 | );
46 | const assetsOnPage = [asset]
47 | .concat(filteredAssets)
48 | .filter(Boolean)
49 | .map((key: string) => ({ nodes: assetNodes[key], data: assetsData[key] }))
50 | .filter(({ nodes }) => nodes && nodes.length > 0);
51 |
52 | if (assetsOnPage.length > 0) {
53 | assetsContainer.appendChild(line);
54 | assetsContainer.appendChild(header);
55 | }
56 |
57 | assetsOnPage.forEach(({ nodes = [], data }: { data: any; nodes: any[] }) => {
58 | const element = document.createElement("div");
59 | const link = constructAssetURL({
60 | spaceId,
61 | asset: data.sys.id,
62 | environment: data.sys.environment && data.sys.environment.sys.id
63 | });
64 |
65 | const linkNode = createElement({
66 | tag: "a",
67 | attrs: {
68 | href: link,
69 | target: "_blank"
70 | },
71 | text: data.fields.title || data.sys.id,
72 | style: {
73 | display: "inline-block",
74 | borderBottom: "1px dashed #ccc",
75 | paddingBottom: "2px",
76 | textDecoration: "none",
77 | marginBottom: "5px"
78 | }
79 | });
80 |
81 | let overlays: Function[] = [];
82 |
83 | const cleanup = onHover({
84 | node: linkNode,
85 | onMouseEnter: () => {
86 | nodes.forEach(node => {
87 | overlays.push(renderOverlay({ node, style: style.overlay }));
88 | });
89 | },
90 | onMouseLeave: cleanOverlays
91 | });
92 |
93 | cleanupFns.push(() => {
94 | cleanOverlays();
95 | cleanup();
96 | });
97 |
98 | element.appendChild(linkNode);
99 | assetsContainer.appendChild(element);
100 |
101 | function cleanOverlays() {
102 | overlays.forEach(fn => fn());
103 | overlays = [];
104 | }
105 | });
106 |
107 | return {
108 | node: assetsContainer,
109 | cleanup: () => {
110 | cleanupFns.forEach(fn => fn());
111 | }
112 | };
113 | }
114 |
--------------------------------------------------------------------------------
/src/popup/renderContentTypes.ts:
--------------------------------------------------------------------------------
1 | import { IEntity } from "../fetch";
2 | import { getContentTypeNodes } from "../state";
3 | import { IStyles } from "../types";
4 | import {
5 | constructContentTypeURL,
6 | createElement,
7 | onHover,
8 | renderOverlay
9 | } from "../utils";
10 |
11 | export function renderContentTypes({
12 | contentTypesData,
13 | spaceId,
14 | style,
15 | contentType
16 | }: {
17 | contentTypesData: { [key: string]: any };
18 | spaceId: string;
19 | style: IStyles;
20 | contentType: string | null;
21 | }) {
22 | const ctsContainer = document.createElement("div");
23 | const line = createElement({
24 | style: {
25 | height: "1px",
26 | margin: "10px 0",
27 | background: "#ccc"
28 | }
29 | });
30 | const header = createElement({
31 | tag: "h3",
32 | text: "Content types on the page:",
33 | style: {
34 | lineHeight: "1.31",
35 | fontSize: "1.17em",
36 | marginTop: "0",
37 | marginBottom: "10px"
38 | }
39 | });
40 |
41 | const cleanupFns: Function[] = [];
42 | const contentTypeNodes = getContentTypeNodes();
43 | const filteredCTNodes = Object.keys(contentTypeNodes).filter(
44 | contentTypeAtPage => contentTypeAtPage !== contentType
45 | );
46 | const ctsOnPage = [contentType]
47 | .concat(filteredCTNodes)
48 | .filter(Boolean)
49 | .map((key: string) => ({
50 | nodes: contentTypeNodes[key],
51 | data: contentTypesData[key]
52 | }))
53 | .filter(({ nodes }) => nodes && nodes.length > 0);
54 |
55 | if (ctsOnPage.length > 0) {
56 | ctsContainer.appendChild(line);
57 | ctsContainer.appendChild(header);
58 | }
59 | ctsOnPage.forEach(({ nodes = [], data }: { data: IEntity; nodes: any[] }) => {
60 | const element = document.createElement("div");
61 | const link = constructContentTypeURL({
62 | spaceId,
63 | contentType: data.sys.id,
64 | environment: data.sys.environment && data.sys.environment.sys.id
65 | });
66 |
67 | const linkNode = createElement({
68 | tag: "a",
69 | attrs: {
70 | href: link,
71 | target: "_blank"
72 | },
73 | text: data.name || "No name property!",
74 | style: {
75 | display: "inline-block",
76 | borderBottom: "1px dashed #ccc",
77 | textDecoration: "none",
78 | paddingBottom: "2px",
79 | marginBottom: "5px"
80 | }
81 | });
82 |
83 | let overlays: Function[] = [];
84 |
85 | const cleanup = onHover({
86 | node: linkNode,
87 | onMouseEnter: () => {
88 | nodes.forEach(node => {
89 | overlays.push(renderOverlay({ node, style: style.overlay }));
90 | });
91 | },
92 | onMouseLeave: cleanOverlays
93 | });
94 |
95 | cleanupFns.push(() => {
96 | cleanOverlays();
97 | cleanup();
98 | });
99 |
100 | element.appendChild(linkNode);
101 | ctsContainer.appendChild(element);
102 |
103 | function cleanOverlays() {
104 | overlays.forEach(fn => fn());
105 | overlays = [];
106 | }
107 | });
108 |
109 | return {
110 | node: ctsContainer,
111 | cleanup: () => {
112 | cleanupFns.forEach(fn => fn());
113 | }
114 | };
115 | }
116 |
--------------------------------------------------------------------------------
/src/popup/renderEntries.ts:
--------------------------------------------------------------------------------
1 | import { getCTEntryNodes } from "../state";
2 | import { IEntryTitle, IStyles } from "../types";
3 | import {
4 | constructEntryURL,
5 | createElement,
6 | getEntryTitle,
7 | onHover,
8 | renderOverlay
9 | } from "../utils";
10 |
11 | export function renderEntries({
12 | contentTypesData,
13 | entriesData,
14 | spaceId,
15 | contentType,
16 | entryTitle,
17 | style
18 | }: {
19 | contentTypesData: { [key: string]: any };
20 | entriesData: { [key: string]: any };
21 | spaceId: string;
22 | contentType: string;
23 | entryTitle?: IEntryTitle;
24 | style: IStyles;
25 | }) {
26 | const contentTypeData = contentTypesData[contentType];
27 | const ctsContainer = document.createElement("div");
28 | const line = createElement({
29 | style: {
30 | height: "1px",
31 | margin: "10px 0",
32 | background: "#ccc"
33 | }
34 | });
35 | const header = createElement({
36 | tag: "h3",
37 | text: `${contentTypeData.name} entries:`,
38 | style: {
39 | lineHeight: "1.31",
40 | fontSize: "1.17em",
41 | marginTop: "0",
42 | marginBottom: "10px"
43 | }
44 | });
45 |
46 | const cleanupFns: Function[] = [];
47 |
48 | const entries = getCTEntryNodes({ contentType });
49 | const entriesKeys = Object.keys(entries)
50 | .map(entryId => ({
51 | entry: entryId,
52 | nodes: entries[entryId],
53 | data: entriesData[entryId]
54 | }))
55 | .filter(({ nodes }) => nodes && nodes.length > 0);
56 |
57 | if (entriesKeys.length > 0) {
58 | ctsContainer.appendChild(line);
59 | ctsContainer.appendChild(header);
60 | }
61 |
62 | entriesKeys.forEach(({ entry, nodes, data }) => {
63 | const element = document.createElement("div");
64 | const link = constructEntryURL({
65 | spaceId,
66 | entry,
67 | environment: data.sys.environment && data.sys.environment.sys.id
68 | });
69 |
70 | const linkNode = createElement({
71 | tag: "a",
72 | attrs: {
73 | href: link,
74 | target: "_blank"
75 | },
76 | text: getEntryTitle({ entry: data, entryTitle }),
77 | style: {
78 | display: "inline-block",
79 | borderBottom: "1px dashed #ccc",
80 | textDecoration: "none",
81 | paddingBottom: "2px",
82 | marginBottom: "5px"
83 | }
84 | });
85 |
86 | let overlays: Function[] = [];
87 |
88 | const cleanup = onHover({
89 | node: linkNode,
90 | onMouseEnter: () => {
91 | nodes.forEach(node => {
92 | overlays.push(renderOverlay({ node, style: style.overlay }));
93 | });
94 | },
95 | onMouseLeave: cleanOverlays
96 | });
97 |
98 | cleanupFns.push(() => {
99 | cleanOverlays();
100 | cleanup();
101 | });
102 |
103 | element.appendChild(linkNode);
104 | ctsContainer.appendChild(element);
105 |
106 | function cleanOverlays() {
107 | overlays.forEach(fn => fn());
108 | overlays = [];
109 | }
110 | });
111 |
112 | return {
113 | node: ctsContainer,
114 | cleanup: () => {
115 | cleanupFns.forEach(fn => fn());
116 | }
117 | };
118 | }
119 |
--------------------------------------------------------------------------------
/src/popup/renderEntriesByCT.ts:
--------------------------------------------------------------------------------
1 | import { getContentTypeNodes } from "../state";
2 | import { IEntryTitle, IStyles } from "../types";
3 | import { createElement } from "../utils";
4 | import { renderEntries } from "./renderEntries";
5 |
6 | export function renderEntriesByCt({
7 | contentType,
8 | contentTypesData,
9 | entriesData,
10 | spaceId,
11 | entryTitle,
12 | style
13 | }: {
14 | contentType: string | null;
15 | contentTypesData: { [key: string]: any };
16 | entriesData: { [key: string]: any };
17 | spaceId: string;
18 | entryTitle?: IEntryTitle;
19 | style: IStyles;
20 | }) {
21 | const container = createElement();
22 | const cleanupFns: Function[] = [];
23 | const filteredCTs = Object.keys(getContentTypeNodes()).filter(
24 | contentTypeAtPage => contentTypeAtPage !== contentType
25 | );
26 | [contentType]
27 | .concat(filteredCTs)
28 | .filter(Boolean)
29 | .forEach((contentTypeAtPage: string) => {
30 | const { node, cleanup } = renderEntries({
31 | contentType: contentTypeAtPage,
32 | contentTypesData,
33 | entriesData,
34 | spaceId,
35 | entryTitle,
36 | style
37 | });
38 |
39 | container.appendChild(node);
40 | cleanupFns.push(cleanup);
41 | });
42 |
43 | return {
44 | node: container,
45 | cleanup: () => {
46 | cleanupFns.forEach(fn => fn());
47 | }
48 | };
49 | }
50 |
--------------------------------------------------------------------------------
/src/state.ts:
--------------------------------------------------------------------------------
1 | import { IStyles } from "./types";
2 |
3 | export interface IContentTypeNodes {
4 | [key: string]: HTMLElement[];
5 | }
6 |
7 | export interface IEntryNodes {
8 | [key: string]: {
9 | [key: string]: HTMLElement[];
10 | };
11 | }
12 |
13 | export interface IAssetNodes {
14 | [key: string]: HTMLElement[];
15 | }
16 |
17 | const contentTypeNodes: IContentTypeNodes = {};
18 | const entryNodes: IEntryNodes = {};
19 | const assetNodes: IAssetNodes = {};
20 |
21 | const styles: { [key: string]: IStyles } = {};
22 |
23 | export function setStyle({
24 | spaceId,
25 | style
26 | }: {
27 | spaceId: string;
28 | style: IStyles;
29 | }) {
30 | styles[spaceId] = style;
31 | }
32 |
33 | export function getStyle({ spaceId }: { spaceId: string }) {
34 | return styles[spaceId];
35 | }
36 |
37 | export function getAssetsNodes(): IAssetNodes {
38 | return assetNodes;
39 | }
40 |
41 | export function getAssetNodes({ asset }: { asset: string }): HTMLElement[] {
42 | return assetNodes[asset] || [];
43 | }
44 |
45 | export function removeAssetNode({
46 | asset,
47 | node
48 | }: {
49 | asset: string;
50 | node: HTMLElement;
51 | }): IAssetNodes {
52 | if (assetNodes[asset]) {
53 | assetNodes[asset] = assetNodes[asset].filter(
54 | assetNode => assetNode !== node
55 | );
56 | }
57 |
58 | return assetNodes;
59 | }
60 |
61 | export function setAssetNode({
62 | asset,
63 | node
64 | }: {
65 | asset: string;
66 | node: HTMLElement;
67 | }): IAssetNodes {
68 | if (!assetNodes[asset]) {
69 | assetNodes[asset] = [];
70 | }
71 |
72 | assetNodes[asset].push(node);
73 |
74 | return assetNodes;
75 | }
76 |
77 | export function getContentTypeNodes(): IContentTypeNodes {
78 | return contentTypeNodes;
79 | }
80 |
81 | export function getContentTypeNode({
82 | contentType
83 | }: {
84 | contentType: string;
85 | }): HTMLElement[] {
86 | return contentTypeNodes[contentType] || [];
87 | }
88 |
89 | export function setContentTypeNode({
90 | contentType,
91 | node
92 | }: {
93 | contentType: string;
94 | node: HTMLElement;
95 | }): IContentTypeNodes {
96 | if (!contentTypeNodes[contentType]) {
97 | contentTypeNodes[contentType] = [];
98 | }
99 |
100 | contentTypeNodes[contentType].push(node);
101 |
102 | return contentTypeNodes;
103 | }
104 |
105 | export function removeContentTypeNode({
106 | contentType,
107 | node: removeNode
108 | }: {
109 | contentType: string;
110 | node: HTMLElement;
111 | }): IContentTypeNodes {
112 | if (contentTypeNodes[contentType]) {
113 | contentTypeNodes[contentType] = contentTypeNodes[contentType].filter(
114 | node => node !== removeNode
115 | );
116 | }
117 |
118 | return contentTypeNodes;
119 | }
120 |
121 | export function getCTEntryNodes({
122 | contentType
123 | }: {
124 | contentType: string;
125 | }): { [key: string]: HTMLElement[] } {
126 | return entryNodes[contentType] || [];
127 | }
128 |
129 | export function getEntryNodes({
130 | contentType,
131 | entry
132 | }: {
133 | contentType: string;
134 | entry: string;
135 | }): HTMLElement[] {
136 | if (entryNodes[contentType] && entryNodes[contentType][entry]) {
137 | return entryNodes[contentType][entry];
138 | }
139 |
140 | return [];
141 | }
142 |
143 | export function setEntryNode({
144 | contentType,
145 | entry,
146 | node
147 | }: {
148 | contentType: string;
149 | entry: string;
150 | node: HTMLElement;
151 | }): IEntryNodes {
152 | if (!entryNodes[contentType]) {
153 | entryNodes[contentType] = {};
154 | }
155 |
156 | if (!entryNodes[contentType][entry]) {
157 | entryNodes[contentType][entry] = [];
158 | }
159 |
160 | entryNodes[contentType][entry].push(node);
161 |
162 | return entryNodes;
163 | }
164 |
165 | export function removeEntryNode({
166 | contentType,
167 | node: entryNode,
168 | entry
169 | }: {
170 | contentType: string;
171 | node: HTMLElement;
172 | entry: string;
173 | }): IEntryNodes {
174 | if (entryNodes[contentType] && entryNodes[contentType][entry]) {
175 | entryNodes[contentType][entry] = entryNodes[contentType][entry].filter(
176 | node => node !== entryNode
177 | );
178 | }
179 |
180 | return entryNodes;
181 | }
182 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export declare type IEntryTitle =
2 | | {
3 | [key: string]: string;
4 | }
5 | | string;
6 |
7 | export interface IStyle {
8 | [key: string]: string;
9 | }
10 |
11 | export interface IStyles {
12 | highlight?: IStyle;
13 | highlightHover?: IStyle;
14 | tooltip?: IStyle;
15 | overlay?: IStyle;
16 | }
17 |
--------------------------------------------------------------------------------
/src/utils/animate.ts:
--------------------------------------------------------------------------------
1 | function sleep(amount: number): Promise {
2 | return new Promise(resolve => {
3 | setTimeout(resolve, amount);
4 | });
5 | }
6 |
7 | export function animate({
8 | node,
9 | time = 200,
10 | start,
11 | stop,
12 | property = "opacity"
13 | }: {
14 | node: HTMLElement;
15 | time?: number;
16 | start: number;
17 | stop: number;
18 | property?: "opacity";
19 | }): Promise {
20 | // no need to re-render more often than 16ms – 1 frame
21 | const steps = Math.ceil(time / 16);
22 | return new Promise(async resolve => {
23 | const period = (stop - start) / steps;
24 |
25 | for (let i = 1; i <= steps; i++) {
26 | node.style[property] = String(start + period * i);
27 | await sleep(time / steps);
28 | }
29 |
30 | resolve();
31 | });
32 | }
33 |
--------------------------------------------------------------------------------
/src/utils/dom.ts:
--------------------------------------------------------------------------------
1 | import { IStyle } from "../types";
2 | import { animate } from "./animate";
3 |
4 | export function renderOverlay({
5 | node,
6 | style
7 | }: {
8 | node: HTMLElement;
9 | style?: IStyle;
10 | }) {
11 | const { bottom, top, left, right } = node.getBoundingClientRect();
12 |
13 | const offsetY = window.pageYOffset;
14 | const offsetX = window.pageXOffset;
15 |
16 | const overlay = createElement({
17 | style: Object.assign({}, style, {
18 | top: `${offsetY + top}px`,
19 | height: `${bottom - top}px`,
20 | left: `${offsetX + left}px`,
21 | width: `${right - left}px`
22 | })
23 | });
24 |
25 | document.body.appendChild(overlay);
26 |
27 | animate({
28 | node: overlay,
29 | start: 0,
30 | stop: 0.25
31 | });
32 |
33 | return async () => {
34 | try {
35 | await animate({
36 | node: overlay,
37 | start: 0.25,
38 | stop: 0
39 | });
40 | document.body.removeChild(overlay);
41 | } catch (e) {
42 | // tslint:disable-next-line no-console
43 | console.log("error during removing overlay::", e);
44 | }
45 | };
46 | }
47 |
48 | export function applyStyle({
49 | node,
50 | style
51 | }: {
52 | node: HTMLElement;
53 | style?: { [key: string]: string } | IStyle;
54 | }): void {
55 | Object.assign(node.style, style);
56 | }
57 |
58 | export function createElement(
59 | {
60 | tag = "div",
61 | text,
62 | style,
63 | attrs
64 | }: {
65 | tag?:
66 | | "div"
67 | | "img"
68 | | "a"
69 | | "h1"
70 | | "h2"
71 | | "h3"
72 | | "h4"
73 | | "h5"
74 | | "h6"
75 | | "span";
76 | text?: HTMLElement | string | null;
77 | style?: { [key: string]: string };
78 | attrs?: { [key: string]: string };
79 | } = {}
80 | ): HTMLElement {
81 | const element = document.createElement(tag);
82 | if (text && typeof text === "string") {
83 | element.innerHTML = text;
84 | } else if (text && typeof text === "object") {
85 | element.appendChild(text);
86 | }
87 |
88 | if (style) {
89 | applyStyle({
90 | node: element,
91 | style
92 | });
93 | }
94 |
95 | if (attrs) {
96 | Object.keys(attrs).forEach(attr => {
97 | const value = attrs[attr];
98 |
99 | element.setAttribute(attr, value);
100 | });
101 | }
102 |
103 | return element;
104 | }
105 |
106 | export function measureHeight(content: HTMLElement | string): number {
107 | const container = createElement({
108 | style: {
109 | position: "absolute",
110 | visibility: "hidden"
111 | },
112 | text: content
113 | });
114 |
115 | document.body.appendChild(container);
116 |
117 | const { height } = container.getBoundingClientRect();
118 |
119 | document.body.removeChild(container);
120 |
121 | return height;
122 | }
123 |
124 | export function containsNode({
125 | node,
126 | checkingNode
127 | }: {
128 | node: HTMLElement;
129 | checkingNode: HTMLElement;
130 | }) {
131 | let inspectingNode: HTMLElement | null = checkingNode;
132 | while (inspectingNode !== null) {
133 | if (inspectingNode === node) {
134 | return true;
135 | }
136 |
137 | inspectingNode = inspectingNode.parentElement;
138 | }
139 |
140 | return false;
141 | }
142 |
143 | export function isBrowser() {
144 | return typeof window !== "undefined" && typeof document !== "undefined";
145 | }
146 |
--------------------------------------------------------------------------------
/src/utils/events.ts:
--------------------------------------------------------------------------------
1 | export function onHover({
2 | node,
3 | onMouseEnter,
4 | onMouseLeave
5 | }: {
6 | node: HTMLElement;
7 | onMouseEnter?: Function;
8 | onMouseLeave?: Function;
9 | }) {
10 | node.addEventListener("mouseenter", internalMouseEnter);
11 |
12 | function internalMouseEnter(e: MouseEvent) {
13 | onMouseEnter && onMouseEnter(e);
14 |
15 | node.addEventListener("mouseleave", internalMouseLeave);
16 | }
17 |
18 | function internalMouseLeave(e: MouseEvent) {
19 | onMouseLeave && onMouseLeave(e);
20 | node.removeEventListener("mouseleave", internalMouseLeave);
21 | }
22 |
23 | return () => {
24 | node.removeEventListener("mouseenter", internalMouseEnter);
25 | };
26 | }
27 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { animate } from "./animate";
2 | import {
3 | applyStyle,
4 | containsNode,
5 | createElement,
6 | isBrowser,
7 | measureHeight,
8 | renderOverlay
9 | } from "./dom";
10 | import { onHover } from "./events";
11 | import {
12 | constructAssetURL,
13 | constructContentTypeURL,
14 | constructEntryURL,
15 | constructSpaceURL,
16 | getEntryTitle
17 | } from "./links";
18 | import { mergeStyle } from "./style";
19 |
20 | export {
21 | onHover,
22 | renderOverlay,
23 | createElement,
24 | applyStyle,
25 | animate,
26 | containsNode,
27 | constructAssetURL,
28 | constructSpaceURL,
29 | constructContentTypeURL,
30 | constructEntryURL,
31 | getEntryTitle,
32 | isBrowser,
33 | mergeStyle,
34 | measureHeight
35 | };
36 |
--------------------------------------------------------------------------------
/src/utils/links.ts:
--------------------------------------------------------------------------------
1 | import { IEntryTitle } from "../types";
2 |
3 | const appPrefix = "https://app.contentful.com";
4 |
5 | export function constructSpaceURL({ spaceId }: { spaceId: string }) {
6 | return `${appPrefix}/spaces/${spaceId}`;
7 | }
8 |
9 | export function constructContentTypeURL({
10 | spaceId,
11 | contentType,
12 | environment
13 | }: {
14 | spaceId: string;
15 | contentType: string;
16 | environment?: string;
17 | }) {
18 | return environment
19 | ? `${appPrefix}/spaces/${spaceId}/environments/${environment}/content_types/${contentType}/fields`
20 | : `${appPrefix}/spaces/${spaceId}/content_types/${contentType}/fields`;
21 | }
22 |
23 | export function constructEntryURL({
24 | spaceId,
25 | entry,
26 | environment
27 | }: {
28 | spaceId: string;
29 | entry: string;
30 | environment?: string;
31 | }) {
32 | return environment
33 | ? `${appPrefix}/spaces/${spaceId}/environments/${environment}/entries/${entry}`
34 | : `${appPrefix}/spaces/${spaceId}/entries/${entry}`;
35 | }
36 |
37 | export function constructMediaURL({
38 | spaceId,
39 | environment
40 | }: {
41 | spaceId: string;
42 | environment: string | undefined;
43 | }) {
44 | return environment
45 | ? `${appPrefix}/spaces/${spaceId}/environments/${environment}/assets`
46 | : `${appPrefix}/spaces/${spaceId}/assets`;
47 | }
48 |
49 | export function constructAssetURL({
50 | spaceId,
51 | asset,
52 | environment
53 | }: {
54 | spaceId: string;
55 | asset: string;
56 | environment?: string;
57 | }) {
58 | return environment
59 | ? `${appPrefix}/spaces/${spaceId}/environments/${environment}/assets/${asset}`
60 | : `${appPrefix}/spaces/${spaceId}/assets/${asset}`;
61 | }
62 |
63 | export function getEntryTitle({
64 | entry,
65 | entryTitle
66 | }: {
67 | entry: any;
68 | entryTitle?: IEntryTitle;
69 | }) {
70 | const field = getEntryTitleField({ entry, entryTitle });
71 | let value;
72 |
73 | [field, "title", "name"].filter(Boolean).some((property: string) => {
74 | value = entry.fields[property];
75 |
76 | return Boolean(value);
77 | });
78 |
79 | return value || entry.sys.id;
80 | }
81 |
82 | function getEntryTitleField({
83 | entry,
84 | entryTitle
85 | }: {
86 | entry: any;
87 | entryTitle?: IEntryTitle;
88 | }) {
89 | if (typeof entryTitle === "string") {
90 | return entryTitle;
91 | } else if (typeof entryTitle === "object") {
92 | return entryTitle[entry.sys.contentType.id];
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/utils/style.ts:
--------------------------------------------------------------------------------
1 | import { IStyles } from "../types";
2 |
3 | const highlightStyles = {
4 | border: "2px dashed red",
5 | transition: "0.3s border"
6 | };
7 |
8 | const highlightHoverStyles = {
9 | border: "2px solid red",
10 | transition: "0.3s border"
11 | };
12 |
13 | const tooltipStyles = {
14 | position: "absolute",
15 | background: "#fff",
16 | zIndex: "999",
17 | minWidth: "150px",
18 | maxWidth: "350px",
19 | maxHeight: "100vh",
20 | overflowY: "scroll",
21 | padding: "15px",
22 | border: "2px solid #ccc",
23 | borderRadius: "5px",
24 | opacity: "0"
25 | };
26 |
27 | const overlayStyles = {
28 | position: "absolute",
29 | opacity: "0",
30 | boxSizing: "border-box",
31 | // let's hope we override everything, except the tooltip
32 | zIndex: "998",
33 | background: "red"
34 | };
35 |
36 | export function mergeStyle(passedStyle: IStyles = {}) {
37 | const newStyle: IStyles = {};
38 |
39 | newStyle.highlight = Object.assign(
40 | {},
41 | highlightStyles,
42 | passedStyle.highlight
43 | );
44 | newStyle.highlightHover = Object.assign(
45 | {},
46 | highlightHoverStyles,
47 | passedStyle.highlightHover
48 | );
49 |
50 | newStyle.tooltip = Object.assign({}, tooltipStyles, passedStyle.tooltip);
51 | newStyle.overlay = Object.assign({}, overlayStyles, passedStyle.overlay);
52 |
53 | return newStyle;
54 | }
55 |
--------------------------------------------------------------------------------
/tsconfig.commonjs.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "declaration": true,
4 | "moduleResolution": "node",
5 | "module": "commonjs",
6 | "target": "es5",
7 | "lib": [
8 | "es5",
9 | "es2015",
10 | "dom"
11 | ],
12 | "noImplicitAny": true,
13 | "sourceMap": true,
14 | "noUnusedParameters": true,
15 | "strictNullChecks": true,
16 | "outDir": "lib/commonjs",
17 | "types": [
18 | ]
19 | },
20 | "files": [
21 | "src/index.ts"
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "noUnusedLocals": true,
4 | "noUnusedParameters": true,
5 | "declaration": true,
6 | "moduleResolution": "node",
7 | "module": "es2015",
8 | "target": "es5",
9 | "lib": [
10 | "es5",
11 | "es2015",
12 | "dom"
13 | ],
14 | "noImplicitAny": true,
15 | "sourceMap": true,
16 | "strictNullChecks": true,
17 | "outDir": "lib/es2015"
18 | },
19 | "files": [
20 | "src/index.ts"
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "defaultSeverity": "error",
3 | "extends": [
4 | "tslint:recommended",
5 | "tslint-config-prettier"
6 | ],
7 | "jsRules": {},
8 | "rules": {
9 | "object-literal-sort-keys": false,
10 | "ban-types": false,
11 | "no-unused-expression": false
12 | },
13 | "rulesDirectory": []
14 | }
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const webpack = require("webpack");
3 | const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
4 |
5 | const isProduction = process.env.NODE_ENV === "production";
6 |
7 | const plugins = isProduction
8 | ? [
9 | new UglifyJsPlugin(),
10 | new webpack.DefinePlugin({
11 | "process.env.NODE_ENV": JSON.stringify("production")
12 | })
13 | ]
14 | : [];
15 |
16 | module.exports = {
17 | entry: "./lib/commonjs/index.js",
18 | output: {
19 | library: "CTFLWizard",
20 | libraryTarget: "umd",
21 | path: path.resolve(__dirname, "dist"),
22 | filename: `contentful-wizard.${isProduction ? "min." : ""}js`
23 | },
24 | plugins
25 | };
26 |
--------------------------------------------------------------------------------