diff --git a/README.md b/README.md index 35e25e4..2c1effb 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ const editor = EditorJS({ class: LinkTool, config: { endpoint: 'http://localhost:8008/fetchUrl', // Your backend endpoint for url data fetching, + createOnPaste: true, // true to catch non-image pasted links and create a preview automatically + key: 'linkTool', // Required if createOnPaste is true - must match the tool key } } }, @@ -71,6 +73,8 @@ Link Tool supports these configuration parameters: | ---------|-------------|------------------------------------------------| | endpoint | `string` | **Required:** the endpoint for link data fetching. | | headers | `object` | **Optional:** the headers used in the GET request. | +| createOnPaste | `boolean` | **Optional:** true to catch non-image pasted links and create a preview automatically. | +| key | `string` | **Optional:** Required if createOnPaste is true - must match the tool key. | ## Output data diff --git a/src/index.css b/src/index.css index 01a0b57..a1ee230 100644 --- a/src/index.css +++ b/src/index.css @@ -19,12 +19,12 @@ background-color: #fff3f6; border-color: #f3e0e0; color: #a95a5a; - box-shadow: inset 0 1px 3px 0 rgba(146, 62, 62, .05); + box-shadow: inset 0 1px 3px 0 rgba(146, 62, 62, 0.05); } } } - &[contentEditable=true][data-placeholder]::before{ + &[contentEditable="true"][data-placeholder]::before { position: absolute; content: attr(data-placeholder); color: #707684; @@ -32,15 +32,14 @@ opacity: 0; } - &[contentEditable=true][data-placeholder]:empty { - + &[contentEditable="true"][data-placeholder]:empty { &::before { opacity: 1; } &:focus::before { - opacity: 0; - } + opacity: 0; + } } } @@ -63,29 +62,22 @@ } &__content { - display: block; - padding: 25px; - border-radius: 2px; - box-shadow: 0 0 0 2px #fff; + display: flex; + column-gap: 10px; + padding: 10px; + border-radius: 5px; color: initial !important; text-decoration: none !important; - &::after { - content: ""; - clear: both; - display: table; - } - &--rendered { background: #fff; - border: 1px solid rgba(201, 201, 204, 0.48); - box-shadow: 0 1px 3px rgba(0,0,0, .1); - border-radius: 6px; + border: 1px solid #e9e9e9; + border-radius: 5px; will-change: filter; animation: link-in 450ms 1 cubic-bezier(0.215, 0.61, 0.355, 1); &:hover { - box-shadow: 0 0 3px rgba(0,0,0, .16); + box-shadow: 0 0 3px rgba(0, 0, 0, 0.16); } } } @@ -94,41 +86,57 @@ background-position: center center; background-repeat: no-repeat; background-size: cover; - margin: 0 0 0 30px; - width: 65px; - height: 65px; - border-radius: 3px; - float: right; + min-width: 80px; + height: 80px; + aspect-ratio: 1/1; + border-radius: 5px; + } + + &__infos { + overflow: hidden; + flex: 1; } &__title { - font-size: 17px; + font-size: 15px; font-weight: 600; - line-height: 1.5em; - margin: 0 0 10px 0; - - + ^&__anchor { - margin-top: 25px; - } + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } &__description { - margin: 0 0 20px 0; - font-size: 15px; - line-height: 1.55em; + font-size: 13px; + color: #595959; + line-height: 1.25; + height: 2rem; + overflow: hidden; display: -webkit-box; - -webkit-line-clamp: 3; -webkit-box-orient: vertical; - overflow: hidden; + -webkit-line-clamp: 2; } &__anchor { - display: block; - font-size: 15px; - line-height: 1em; - color: #888 !important; - border: 0 !important; - padding: 0 !important; + font-size: 13px; + color: #f44545; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &.highlight { + .link-tool { + &__content { + flex-direction: column; + } + + &__image { + flex-direction: column; + width: 100%; + height: auto; + aspect-ratio: 4/3; + } + } } } diff --git a/src/index.js b/src/index.js index ad92ca2..4a516dd 100644 --- a/src/index.js +++ b/src/index.js @@ -17,12 +17,18 @@ * @typedef {object} LinkToolConfig * @property {string} endpoint - the endpoint for link data fetching * @property {object} headers - the headers used in the GET request + * @property {boolean} createOnPaste - true to catch non-image pasted links and create a preview automatically + * @property {string} key - Required if createOnPaste is true - must match the tool key, i.e. the key used in the editorjs config */ import './index.css'; import 'url-polyfill'; import ajax from '@codexteam/ajax'; import { IconLink } from '@codexteam/icons'; +import { isVideoLink } from './videoLink'; + +const noPreviewIcon = + ''; /** * @typedef {object} UploadResponseFormat @@ -67,11 +73,26 @@ export default class LinkTool { return true; } + /** + * Available image tools + * + * @returns {Array} + */ + static get tunes() { + return [ + { + name: 'urlOnly', + icon: noPreviewIcon, + title: 'Remove preview', + }, + ]; + } + /** * @param {object} options - Tool constructor options fot from Editor.js * @param {LinkToolData} options.data - previously saved data * @param {LinkToolConfig} options.config - user config for Tool - * @param {object} options.api - Editor.js API + * @param {import('@editorjs/editorjs').API} options.api - Editor.js API * @param {boolean} options.readOnly - read-only mode flag */ constructor({ data, config, api, readOnly }) { @@ -82,6 +103,7 @@ export default class LinkTool { * Tool's initial config */ this.config = { + ...config, endpoint: config.endpoint || '', headers: config.headers || {}, }; @@ -94,6 +116,7 @@ export default class LinkTool { inputHolder: null, linkContent: null, linkImage: null, + linkInfos: null, linkTitle: null, linkDescription: null, linkText: null, @@ -105,6 +128,22 @@ export default class LinkTool { }; this.data = data; + + if (config.createOnPaste) { + this.api.listeners.on( + this.api.ui.nodes.wrapper, + 'paste', + this.handlePaste.bind(this) + ); + + // If a block was programmatically created with a link, start fetching + setTimeout(() => { + if (data.link) { + this.nodes.input.textContent = data.link; + this.startFetching({}, true); + } + }); + } } /** @@ -117,6 +156,7 @@ export default class LinkTool { render() { this.nodes.wrapper = this.make('div', this.CSS.baseClass); this.nodes.container = this.make('div', this.CSS.container); + this.nodes.container = this.make('div', [this.CSS.container, 'not-prose']); this.nodes.inputHolder = this.makeInputHolder(); this.nodes.linkContent = this.prepareLinkPreview(); @@ -165,10 +205,13 @@ export default class LinkTool { * @param {LinkToolData} data - data to store */ set data(data) { - this._data = Object.assign({}, { - link: data.link || this._data.link, - meta: data.meta || this._data.meta, - }); + this._data = Object.assign( + {}, + { + link: data.link || this._data.link, + meta: data.meta || this._data.meta, + } + ); } /** @@ -198,6 +241,7 @@ export default class LinkTool { linkContent: 'link-tool__content', linkContentRendered: 'link-tool__content--rendered', linkImage: 'link-tool__image', + linkInfos: 'link-tool__infos', linkTitle: 'link-tool__title', linkDescription: 'link-tool__description', linkText: 'link-tool__anchor', @@ -257,8 +301,9 @@ export default class LinkTool { * Activates link data fetching by url * * @param {PasteEvent|KeyboardEvent} event - fetching could be fired by a pase or keydown events + * @param {boolean} fallbackToText if true and the fetch fails, falls back to rendering a paragraph block with the raw link instead of a link block with a failure. */ - startFetching(event) { + startFetching(event, fallbackToText) { let url = this.nodes.input.textContent; if (event.type === 'paste') { @@ -266,7 +311,7 @@ export default class LinkTool { } this.removeErrorStyle(); - this.fetchLinkData(url); + this.fetchLinkData(url, fallbackToText); } /** @@ -311,6 +356,7 @@ export default class LinkTool { }); this.nodes.linkImage = this.make('div', this.CSS.linkImage); + this.nodes.linkInfos = this.make('div', this.CSS.linkInfos); this.nodes.linkTitle = this.make('div', this.CSS.linkTitle); this.nodes.linkDescription = this.make('p', this.CSS.linkDescription); this.nodes.linkText = this.make('span', this.CSS.linkText); @@ -333,23 +379,20 @@ export default class LinkTool { if (title) { this.nodes.linkTitle.textContent = title; - this.nodes.linkContent.appendChild(this.nodes.linkTitle); + this.nodes.linkInfos.appendChild(this.nodes.linkTitle); } if (description) { this.nodes.linkDescription.textContent = description; - this.nodes.linkContent.appendChild(this.nodes.linkDescription); + this.nodes.linkInfos.appendChild(this.nodes.linkDescription); } + this.nodes.linkText.textContent = this.data.link; + this.nodes.linkInfos.appendChild(this.nodes.linkText); + this.nodes.linkContent.classList.add(this.CSS.linkContentRendered); this.nodes.linkContent.setAttribute('href', this.data.link); - this.nodes.linkContent.appendChild(this.nodes.linkText); - - try { - this.nodes.linkText.textContent = (new URL(this.data.link)).hostname; - } catch (e) { - this.nodes.linkText.textContent = this.data.link; - } + this.nodes.linkContent.appendChild(this.nodes.linkInfos); } /** @@ -385,26 +428,48 @@ export default class LinkTool { * Sends to backend pasted url and receives link data * * @param {string} url - link source url + * @param {boolean} fallbackToText if true and the fetch fails, falls back to rendering a paragraph block with the raw link instead of a link block with a failure. */ - async fetchLinkData(url) { + async fetchLinkData(url, fallbackToText) { this.showProgress(); this.data = { link: url }; try { - const { body } = await (ajax.get({ + const { body } = await ajax.get({ url: this.config.endpoint, headers: this.config.headers, data: { url, }, - })); + }); this.onFetch(body); } catch (error) { - this.fetchingFailed(this.api.i18n.t('Couldn\'t fetch the link data')); + if (fallbackToText) { + this.replaceBlockWithParagraph(); + } else { + this.fetchingFailed(this.api.i18n.t("Couldn't fetch the link data")); + } } } + /** + * Replace this link block with a standard paragraph (text) block. Example: as a fallback for pasted URLs which fetch failed. + */ + replaceBlockWithParagraph() { + const newBlock = this.api.blocks.insert( + 'paragraph', + { text: this.nodes.input.textContent }, + undefined, + this.api.blocks.getCurrentBlockIndex(), + true, + true + ); + + this.api.caret.setToBlock(newBlock.id); + this.api.toolbar.toggleBlockSettings(false); + } + /** * Link data fetching callback * @@ -412,7 +477,9 @@ export default class LinkTool { */ onFetch(response) { if (!response || !response.success) { - this.fetchingFailed(this.api.i18n.t('Couldn\'t get this link data, try the other one')); + this.fetchingFailed( + this.api.i18n.t("Couldn't get this link data, try the other one") + ); return; } @@ -427,7 +494,9 @@ export default class LinkTool { }; if (!metaData) { - this.fetchingFailed(this.api.i18n.t('Wrong response format from the server')); + this.fetchingFailed( + this.api.i18n.t('Wrong response format from the server') + ); return; } @@ -477,4 +546,109 @@ export default class LinkTool { return el; } + + /** + * Custom paste handler, so that we can choose more accurately whether it should catch the paste or not. + * + * @param {PasteEvent | KeyboardEvent} event the paste event + */ + handlePaste(event) { + const pasteConfig = { + patterns: { + embed: /^(?!.*\.(?:gif|jpe?g|tiff|png)(?:\?|$))https?:\/\/\S+$/i, + }, + }; + const patterns = pasteConfig.patterns; + const url = (event.clipboardData || window.clipboardData).getData('text'); + + if (!url) { + return; + } + + const currentBlock = this.api.blocks.getBlockByIndex( + this.api.blocks.getCurrentBlockIndex() + ); + const isParagraph = currentBlock.name === 'paragraph'; + const isCurrentBlockEmpty = currentBlock.isEmpty; + const shouldDisplayEmbedLink = + patterns.embed.test(url) && !isVideoLink(url); + + if (shouldDisplayEmbedLink && isParagraph && isCurrentBlockEmpty) { + event.preventDefault(); // Prevent the default paste behavior + this.insertPastedBlock(url); + } + } + + /** + * Insert a link block + * + * @param {string} link the URL to include in the created link block + */ + insertPastedBlock(link) { + const pluginName = this.getPluginName(); + + this.api.blocks.insert( + pluginName, + { link }, + undefined, + this.api.blocks.getCurrentBlockIndex(), + true, + true + ); + setTimeout(() => { + this.api.toolbar.toggleBlockSettings(true); + }); + } + + /** + * Get the name of the plugin dynamically from the Editor.js configuration. + * Ideally, we should get it dynamically without requiring the user to provide a config, but I haven't found how to do that. + * + * @returns {string} The name of the plugin + */ + getPluginName() { + if (!this.config.key) { + throw new Error( + `You need to provide the tool key in the plugin config, e.g. { linkTool: { class: LinkTool, config: { key: 'linkTool' } } }` + ); + } + + return this.config.key; + } + + /** + * Returns configuration for block tunes: remove the preview and keep the URL only + * + * @public + * + * @returns {Array} + */ + renderSettings() { + // Merge default tunes with the ones that might be added by user + // @see https://github.com/editor-js/image/pull/49 + const tunes = + this.config.actions && this.config.createOnPaste + ? LinkTool.tunes.concat(this.config.actions) + : LinkTool.tunes; + + return tunes.map((tune) => ({ + icon: tune.icon, + label: this.api.i18n.t(tune.title), + name: tune.name, + toggle: tune.toggle, + isActive: this.data[tune.name], + onActivate: () => { + /* If it'a user defined tune, execute it's callback stored in action property */ + if (typeof tune.action === 'function') { + tune.action(tune.name); + + return; + } + + if (tune.name === 'urlOnly') { + this.replaceBlockWithParagraph(); + } + }, + })); + } } diff --git a/src/videoLink.js b/src/videoLink.js new file mode 100644 index 0000000..590a827 --- /dev/null +++ b/src/videoLink.js @@ -0,0 +1,28 @@ +/* eslint-disable */ + +const videoLinkRegex = { + vimeo: + /(?:http[s]?:\/\/)?(?:www.)?(?:player.)?vimeo\.co(?:.+\/([^\/]\d+)(?:#t=[\d]+)?s?$)/, + youtube: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/, + dailymotion: /(?:https?:\/\/)?(?:www\.)?dailymotion\.com\/video\/([^_]+)/, + coub: /https?:\/\/coub\.com\/view\/([^\/\?\&]+)/, + vine: /https?:\/\/vine\.co\/v\/([^\/\?\&]+)/, + imgur: /https?:\/\/(?:i\.)?imgur\.com.*\/([a-zA-Z0-9]+)(?:\.gifv)?/, + gfycat: /https?:\/\/gfycat\.com(?:\/detail)?\/([a-zA-Z]+)/, + 'twitch-channel': /https?:\/\/www\.twitch\.tv\/([^\/\?\&]*)\/?$/, + 'twitch-video': /https?:\/\/www\.twitch\.tv\/(?:[^\/\?\&]*\/v|videos)\/([0-9]*)/, + 'yandex-music-album': /https?:\/\/music\.yandex\.ru\/album\/([0-9]*)\/?$/, + 'yandex-music-track': /https?:\/\/music\.yandex\.ru\/album\/([0-9]*)\/track\/([0-9]*)/, + 'yandex-music-playlist': /https?:\/\/music\.yandex\.ru\/users\/([^\/\?\&]*)\/playlists\/([0-9]*)/, + codepen: /https?:\/\/codepen\.io\/([^\/\?\&]*)\/pen\/([^\/\?\&]*)/, + instagram: /https?:\/\/www\.instagram\.com\/p\/([^\/\?\&]+)\/?.*/, + twitter: /^https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+?.*)?$/, + pinterest: /https?:\/\/([^\/\?\&]*).pinterest.com\/pin\/([^\/\?\&]*)\/?$/, + facebook: /https?:\/\/www.facebook.com\/([^\/\?\&]*)\/(.*)/, + aparat: /(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/, + miro: /https:\/\/miro.com\/\S+(\S{12})\/(\S+)?/, + github: /https?:\/\/gist.github.com\/([^\/\?\&]*)\/([^\/\?\&]*)/, +}; + +export const isVideoLink = (string) => + Object.values(videoLinkRegex).some((videoRegex) => videoRegex.test(string));