· 6 years ago · Jan 29, 2020, 06:26 AM
1import SERVICES from './services';
2import './index.css';
3import {debounce} from 'debounce';
4
5/**
6 * @typedef {Object} EmbedData
7 * @description Embed Tool data
8 * @property {string} service - service name
9 * @property {string} url - source URL of embedded content
10 * @property {string} embed - URL to source embed page
11 * @property {number} [width] - embedded content width
12 * @property {number} [height] - embedded content height
13 * @property {string} [caption] - content caption
14 *
15 * @typedef {Object} Service
16 * @description Service configuration object
17 * @property {RegExp} regex - pattern of source URLs
18 * @property {string} embedUrl - URL scheme to embedded page. Use '<%= remote_id %>' to define a place to insert resource id
19 * @property {string} html - iframe which contains embedded content
20 * @property {number} [height] - iframe height
21 * @property {number} [width] - iframe width
22 * @property {Function} [id] - function to get resource id from RegExp groups
23 *
24 * @typedef {Object} EmbedConfig
25 * @description Embed tool configuration object
26 * @property {Object} [services] - additional services provided by user. Each property should contain Service object
27 */
28
29/**
30 * @class Embed
31 * @classdesc Embed Tool for Editor.js 2.0
32 *
33 * @property {Object} api - Editor.js API
34 * @property {EmbedData} _data - private property with Embed data
35 * @property {HTMLElement} element - embedded content container
36 *
37 * @property {Object} services - static property with available services
38 * @property {Object} patterns - static property with patterns for paste handling configuration
39 */
40export default class Embed {
41 /**
42 * @param {{data: EmbedData, config: EmbedConfig, api: object}}
43 * data — previously saved data
44 * config - user config for Tool
45 * api - Editor.js API
46 */
47 constructor({data, api}) {
48 this.api = api;
49 this._data = {};
50 this.element = null;
51
52 this.data = data;
53 }
54
55 static get toolbox() {
56 return {
57 icon: `<svg width="14" height="14" viewBox="0 -1 14 14" xmlns="http://www.w3.org/2000/svg" > <path d="M3.177 6.852c.205.253.347.572.427.954.078.372.117.844.117 1.417 0 .418.01.725.03.92.02.18.057.314.107.396.046.075.093.117.14.134.075.027.218.056.42.083a.855.855 0 0 1 .56.297c.145.167.215.38.215.636 0 .612-.432.934-1.216.934-.457 0-.87-.087-1.233-.262a1.995 1.995 0 0 1-.853-.751 2.09 2.09 0 0 1-.305-1.097c-.014-.648-.029-1.168-.043-1.56-.013-.383-.034-.631-.06-.733-.064-.263-.158-.455-.276-.578a2.163 2.163 0 0 0-.505-.376c-.238-.134-.41-.256-.519-.371C.058 6.76 0 6.567 0 6.315c0-.37.166-.657.493-.846.329-.186.56-.342.693-.466a.942.942 0 0 0 .26-.447c.056-.2.088-.42.097-.658.01-.25.024-.85.043-1.802.015-.629.239-1.14.672-1.522C2.691.19 3.268 0 3.977 0c.783 0 1.216.317 1.216.921 0 .264-.069.48-.211.643a.858.858 0 0 1-.563.29c-.249.03-.417.076-.498.126-.062.04-.112.134-.139.291-.031.187-.052.562-.061 1.119a8.828 8.828 0 0 1-.112 1.378 2.24 2.24 0 0 1-.404.963c-.159.212-.373.406-.64.583.25.163.454.342.612.538zm7.34 0c.157-.196.362-.375.612-.538a2.544 2.544 0 0 1-.641-.583 2.24 2.24 0 0 1-.404-.963 8.828 8.828 0 0 1-.112-1.378c-.009-.557-.03-.932-.061-1.119-.027-.157-.077-.251-.14-.29-.08-.051-.248-.096-.496-.127a.858.858 0 0 1-.564-.29C8.57 1.401 8.5 1.185 8.5.921 8.5.317 8.933 0 9.716 0c.71 0 1.286.19 1.72.574.432.382.656.893.671 1.522.02.952.033 1.553.043 1.802.009.238.041.458.097.658a.942.942 0 0 0 .26.447c.133.124.364.28.693.466a.926.926 0 0 1 .493.846c0 .252-.058.446-.183.58-.109.115-.281.237-.52.371-.21.118-.377.244-.504.376-.118.123-.212.315-.277.578-.025.102-.045.35-.06.733-.013.392-.027.912-.042 1.56a2.09 2.09 0 0 1-.305 1.097c-.2.323-.486.574-.853.75a2.811 2.811 0 0 1-1.233.263c-.784 0-1.216-.322-1.216-.934 0-.256.07-.47.214-.636a.855.855 0 0 1 .562-.297c.201-.027.344-.056.418-.083.048-.017.096-.06.14-.134a.996.996 0 0 0 .107-.396c.02-.195.031-.502.031-.92 0-.573.039-1.045.117-1.417.08-.382.222-.701.427-.954z" /> </svg>`,
58 title: 'Embed'
59 };
60 }
61
62 /**
63 * @param {EmbedData} data
64 * @param {RegExp} [data.regex] - pattern of source URLs
65 * @param {string} [data.embedUrl] - URL scheme to embedded page. Use '<%= remote_id %>' to define a place to insert resource id
66 * @param {string} [data.html] - iframe which contains embedded content
67 * @param {number} [data.height] - iframe height
68 * @param {number} [data.width] - iframe width
69 * @param {string} [data.caption] - caption
70 */
71 set data(data) {
72 if (!(data instanceof Object)) {
73 throw Error('Embed Tool data should be object');
74 }
75
76 const {service, source, embed, width, height, caption = ''} = data;
77
78 this._data = {
79 service: service || this.data.service,
80 source: source || this.data.source,
81 embed: embed || this.data.embed,
82 width: width || this.data.width,
83 height: height || this.data.height,
84 caption: caption || this.data.caption || '',
85 };
86
87 const oldView = this.element;
88
89 if (oldView) {
90 oldView.parentNode.replaceChild(this.render(), oldView);
91 }
92 }
93
94 /**
95 * @return {EmbedData}
96 */
97 get data() {
98 if (this.element) {
99 const caption = this.element.querySelector(`.${this.api.styles.input}`);
100
101 this._data.caption = caption ? caption.innerHTML : '';
102 }
103
104 return this._data;
105 }
106
107 /**
108 * Get plugin styles
109 * @return {Object}
110 */
111 get CSS() {
112 return {
113 baseClass: this.api.styles.block,
114 input: this.api.styles.input,
115 container: 'embed-tool',
116 containerLoading: 'embed-tool--loading',
117 preloader: 'embed-tool__preloader',
118 caption: 'embed-tool__caption',
119 url: 'embed-tool__url',
120 content: 'embed-tool__content'
121 };
122 }
123
124 /**
125 * Render Embed tool content
126 *
127 * @return {HTMLElement}
128 */
129 render() {
130 if (!this.data.service) {
131 const container = document.createElement('input');
132
133 this.element = container;
134
135 return container;
136 }
137
138 const {html} = Embed.services[this.data.service];
139 const container = document.createElement('div');
140 const caption = document.createElement('div');
141 const template = document.createElement('template');
142 const preloader = this.createPreloader();
143
144 container.classList.add(this.CSS.baseClass, this.CSS.container, this.CSS.containerLoading);
145 caption.classList.add(this.CSS.input, this.CSS.caption);
146
147 container.appendChild(preloader);
148
149 caption.contentEditable = true;
150 caption.dataset.placeholder = 'Enter a caption';
151 caption.innerHTML = this.data.caption || '';
152
153 template.innerHTML = html;
154 template.content.firstChild.setAttribute('src', this.data.embed);
155 template.content.firstChild.classList.add(this.CSS.content);
156
157 const embedIsReady = this.embedIsReady(container);
158
159 container.appendChild(template.content.firstChild);
160 container.appendChild(caption);
161
162 embedIsReady
163 .then(() => {
164 container.classList.remove(this.CSS.containerLoading);
165 });
166
167 this.element = container;
168
169 return container;
170 }
171
172 rendered() {
173 const _this = this;
174 this.element.addEventListener('paste', function(e) {
175 // const {key: service, data: url} = event.detail;
176 const service = 'youtube';
177 const url = 'https://www.youtube.com/watch?v=eYSmNP3woow';
178
179 const {regex, embedUrl, width, height, id = (ids) => ids.shift()} = Embed.services[service];
180 const result = regex.exec(url).slice(1);
181 const embed = embedUrl.replace(/<\%\= remote\_id \%\>/g, id(result));
182
183 _this.data = {
184 service,
185 source: url,
186 embed,
187 width,
188 height
189 };
190 console.log("url", _this.data)
191
192 })
193 }
194
195 /**
196 * Creates preloader to append to container while data is loading
197 * @return {HTMLElement} preloader
198 */
199 createPreloader() {
200 const preloader = document.createElement('preloader');
201 const url = document.createElement('div');
202
203 url.textContent = this.data.source;
204
205 preloader.classList.add(this.CSS.preloader);
206 url.classList.add(this.CSS.url);
207
208 preloader.appendChild(url);
209
210 return preloader;
211 }
212
213 /**
214 * Save current content and return EmbedData object
215 *
216 * @return {EmbedData}
217 */
218 save() {
219 return this.data;
220 }
221
222 /**
223 * Handle pasted url and return Service object
224 *
225 * @param {PasteEvent} event- event with pasted data
226 * @return {Service}
227 */
228 onPaste(event) {
229 console.log(event)
230 const {key: service, data: url} = event.detail;
231
232 const {regex, embedUrl, width, height, id = (ids) => ids.shift()} = Embed.services[service];
233 const result = regex.exec(url).slice(1);
234 const embed = embedUrl.replace(/<\%\= remote\_id \%\>/g, id(result));
235
236 this.data = {
237 service,
238 source: url,
239 embed,
240 width,
241 height
242 };
243 }
244
245 /**
246 * Analyze provided config and make object with services to use
247 *
248 * @param {EmbedConfig} config
249 */
250 static prepare({config = {}}) {
251 let {services = {}} = config;
252
253 let entries = Object.entries(SERVICES);
254
255 const enabledServices = Object
256 .entries(services)
257 .filter(([key, value]) => {
258 return typeof value === 'boolean' && value === true;
259 })
260 .map(([ key ]) => key);
261
262 const userServices = Object
263 .entries(services)
264 .filter(([key, value]) => {
265 return typeof value === 'object';
266 })
267 .filter(([key, service]) => Embed.checkServiceConfig(service))
268 .map(([key, service]) => {
269 const {regex, embedUrl, html, height, width, id} = service;
270
271 return [key, {
272 regex,
273 embedUrl,
274 html,
275 height,
276 width,
277 id
278 } ];
279 });
280
281 if (enabledServices.length) {
282 entries = entries.filter(([ key ]) => enabledServices.includes(key));
283 }
284
285 entries = entries.concat(userServices);
286
287 Embed.services = entries.reduce((result, [key, service]) => {
288 if (!(key in result)) {
289 result[key] = service;
290 return result;
291 }
292
293 result[key] = Object.assign({}, result[key], service);
294 return result;
295 }, {});
296
297 Embed.patterns = entries
298 .reduce((result, [key, item]) => {
299 result[key] = item.regex;
300
301 return result;
302 }, {});
303 }
304
305 /**
306 * Check if Service config is valid
307 *
308 * @param {Service} config
309 * @return {boolean}
310 */
311 static checkServiceConfig(config) {
312 const {regex, embedUrl, html, height, width, id} = config;
313
314 let isValid = regex && regex instanceof RegExp
315 && embedUrl && typeof embedUrl === 'string'
316 && html && typeof html === 'string';
317
318 isValid = isValid && (id !== undefined ? id instanceof Function : true);
319 isValid = isValid && (height !== undefined ? Number.isFinite(height) : true);
320 isValid = isValid && (width !== undefined ? Number.isFinite(width) : true);
321
322 return isValid;
323 }
324
325 /**
326 * Paste configuration to enable pasted URLs processing by Editor
327 */
328 static get pasteConfig() {
329 return {
330 patterns: Embed.patterns
331 };
332 }
333
334 /**
335 * Checks that mutations in DOM have finished after appending iframe content
336 * @param {HTMLElement} targetNode - HTML-element mutations of which to listen
337 * @return {Promise<any>} - result that all mutations have finished
338 */
339 embedIsReady(targetNode) {
340 const PRELOADER_DELAY = 450;
341
342 let observer = null;
343
344 return new Promise((resolve, reject) => {
345 observer = new MutationObserver(debounce(resolve, PRELOADER_DELAY));
346 observer.observe(targetNode, {childList: true, subtree: true});
347 }).then(() => {
348 observer.disconnect();
349 });
350 }
351}