├── .babelrc ├── .eslintrc ├── .github └── workflows │ ├── npm-publish.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── LICENSE.md ├── README.md ├── package.json ├── postcss.config.js ├── src ├── index.css ├── index.ts ├── serviceConfig.ts └── services.ts ├── test └── services.ts ├── tsconfig.json ├── vite.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": ["@babel/plugin-transform-runtime"] 4 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["codex"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to NPM 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 19 16 | registry-url: https://registry.npmjs.org/ 17 | - run: yarn 18 | - run: yarn build 19 | - run: yarn publish --access=public 20 | env: 21 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 22 | notify: 23 | needs: publish 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Get package info 28 | id: package 29 | uses: codex-team/action-nodejs-package-info@v1 30 | - name: Send a message 31 | uses: codex-team/action-codexbot-notify@v1 32 | with: 33 | webhook: ${{ secrets.CODEX_BOT_NOTIFY_EDITORJS_PUBLIC_CHAT }} 34 | message: '📦 [${{ steps.package.outputs.name }}](${{ steps.package.outputs.npmjs-link }}) ${{ steps.package.outputs.version }} was published' 35 | parse_mode: 'markdown' 36 | disable_web_page_preview: true 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Internal patterns test 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | lint: 7 | name: Patterns 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v1 12 | with: 13 | node-version: 16 14 | registry-url: https://registry.npmjs.org/ 15 | 16 | - name: Cache node modules 17 | uses: actions/cache@v1 18 | with: 19 | path: node_modules 20 | key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | ${{ runner.OS }}-build-${{ env.cache-name }}- 23 | ${{ runner.OS }}-build- 24 | ${{ runner.OS }}- 25 | 26 | - run: yarn install 27 | - run: yarn build 28 | - run: yarn test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | npm-debug.log 3 | .idea/ 4 | .DS_Store 5 | dist 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | docs/ 3 | src/ 4 | test/ 5 | vite.config.js 6 | postcss.config.js 7 | .babelrc 8 | yarn.lock 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 CodeX 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://badgen.net/badge/Editor.js/v2.0/blue) 2 | 3 | # Embed Tool 4 | 5 | Provides Block tool for embedded content for the [Editor.js](https://editorjs.io). 6 | Tool uses Editor.js pasted patterns handling and inserts iframe with embedded content. 7 | 8 | ## List of services supported 9 | 10 | > `service` — is a service name that will be saved to Tool's [output JSON](#output-data) 11 | 12 | - [Facebook](https://www.facebook.com) - `facebook` service 13 | - [Instagram](https://www.instagram.com/codex_team/) - `instagram` service 14 | - [YouTube](https://youtube.com) - `youtube` service 15 | - [Twitter](https://twitter.com) - `twitter` service. (official twitter api is used for render, no need to use twitframe) 16 | - [Twitch](https://twitch.tv) - `twitch-video` service for videos and `twitch-channel` for channels 17 | - [Miro](https://miro.com) - `miro` service 18 | - [Vimeo](https://vimeo.com) — `vimeo` service 19 | - [Gfycat](https://gfycat.com) — `gfycat` service 20 | - [Imgur](https://imgur.com) — `imgur` service 21 | - [Vine](https://vine.co) - `vine` service. The project is in archive state now 22 | - [Aparat](https://www.aparat.com) - `aparat` service 23 | - [Yandex.Music](https://music.yandex.ru) - `yandex-music-track` service for tracks, `yandex-music-album` for albums and `yandex-music-playlist` for playlists 24 | - [Coub](https://coub.com) — `coub` service 25 | - [CodePen](https://codepen.io) — `codepen` service 26 | - [Pinterest](https://www.pinterest.com) - `pinterest` service 27 | - [GitHub Gist](https://gist.github.com) - `github` service 28 | - 👇 Any other [customized service](#add-more-services) 29 | 30 | 31 | 32 | ## Installation 33 | 34 | Get the package 35 | 36 | ```shell 37 | yarn add @editorjs/embed 38 | ``` 39 | 40 | Include module at your application 41 | 42 | ```javascript 43 | import Embed from '@editorjs/embed'; 44 | ``` 45 | 46 | Optionally, you can load this tool from CDN [JsDelivr CDN](https://cdn.jsdelivr.net/npm/@editorjs/embed@latest) 47 | 48 | ## Usage 49 | 50 | Add a new Tool to the `tools` property of the Editor.js initial config. 51 | 52 | ```javascript 53 | var editor = EditorJS({ 54 | ... 55 | 56 | tools: { 57 | ... 58 | embed: Embed, 59 | }, 60 | 61 | ... 62 | }); 63 | ``` 64 | 65 | ## Available configuration 66 | 67 | ### Enabling / disabling services 68 | 69 | Embed Tool supports some services by default (see above). You can specify services you would like to use: 70 | 71 | ```javascript 72 | var editor = EditorJS({ 73 | ... 74 | 75 | tools: { 76 | ... 77 | embed: { 78 | class: Embed, 79 | config: { 80 | services: { 81 | youtube: true, 82 | coub: true 83 | } 84 | } 85 | }, 86 | }, 87 | 88 | ... 89 | }); 90 | ``` 91 | 92 | > Note that if you pass services you want to use like in the example above, others will not be enabled. 93 | 94 | ### Add more services 95 | 96 | You can provide your own services using simple configuration. 97 | 98 | First, you should create a Service configuration object. It contains following fields: 99 | 100 | | Field | Type | Description | 101 | | ---------- | ---------- | ----------- | 102 | | `regex` | `RegExp` | Pattern of pasted URLs. You should use regexp groups to extract resource id 103 | | `embedUrl` | `string` | Url of resource\`s embed page. Use `<%= remote_id %>` to substitute resource identifier 104 | | `html` | `string` | HTML code of iframe with embedded content. `embedUrl` will be set as iframe `src` 105 | | `height` | `number` | _Optional_. Height of inserted iframe 106 | | `width` | `number` | _Optional_. Width of inserted iframe 107 | | `id` | `Function` | _Optional_. If your id is complex you can provide function to make the id from extraced regexp groups 108 | 109 | Example: 110 | 111 | ```javascript 112 | { 113 | regex: /https?:\/\/codepen.io\/([^\/\?\&]*)\/pen\/([^\/\?\&]*)/, 114 | embedUrl: 'https://codepen.io/<%= remote_id %>?height=300&theme-id=0&default-tab=css,result&embed-version=2', 115 | html: "", 116 | height: 300, 117 | width: 600, 118 | id: (groups) => groups.join('/embed/') 119 | } 120 | ``` 121 | 122 | When you create a Service configuration object, you can provide it with Tool\`s configuration: 123 | 124 | ```javascript 125 | var editor = EditorJS({ 126 | ... 127 | 128 | tools: { 129 | ... 130 | embed: { 131 | class: Embed, 132 | config: { 133 | services: { 134 | youtube: true, 135 | coub: true, 136 | codepen: { 137 | regex: /https?:\/\/codepen.io\/([^\/\?\&]*)\/pen\/([^\/\?\&]*)/, 138 | embedUrl: 'https://codepen.io/<%= remote_id %>?height=300&theme-id=0&default-tab=css,result&embed-version=2', 139 | html: "", 140 | height: 300, 141 | width: 600, 142 | id: (groups) => groups.join('/embed/') 143 | } 144 | } 145 | } 146 | }, 147 | }, 148 | 149 | ... 150 | }); 151 | ``` 152 | 153 | #### Inline Toolbar 154 | Editor.js provides useful inline toolbar. You can allow it\`s usage in the Embed Tool caption by providing `inlineToolbar: true`. 155 | 156 | ```javascript 157 | var editor = EditorJS({ 158 | ... 159 | 160 | tools: { 161 | ... 162 | embed: { 163 | class: Embed, 164 | inlineToolbar: true 165 | }, 166 | }, 167 | 168 | ... 169 | }); 170 | ``` 171 | 172 | ## Output data 173 | 174 | | Field | Type | Description 175 | | ------- | -------- | ----------- 176 | | service | `string` | service unique name 177 | | source | `string` | source URL 178 | | embed | `string` | URL for source embed page 179 | | width | `number` | embedded content width 180 | | height | `number` | embedded content height 181 | | caption | `string` | content caption 182 | 183 | 184 | ```json 185 | { 186 | "type" : "embed", 187 | "data" : { 188 | "service" : "coub", 189 | "source" : "https://coub.com/view/1czcdf", 190 | "embed" : "https://coub.com/embed/1czcdf", 191 | "width" : 580, 192 | "height" : 320, 193 | "caption" : "My Life" 194 | } 195 | } 196 | ``` 197 | 198 | # About CodeX 199 | 200 | 201 | 202 | CodeX is a team of digital specialists around the world interested in building high-quality open source products on a global market. We are [open](https://codex.so/join) for young people who want to constantly improve their skills and grow professionally with experiments in cutting-edge technologies. 203 | 204 | | 🌐 | Join 👋 | Twitter | Instagram | 205 | | -- | -- | -- | -- | 206 | | [codex.so](https://codex.so) | [codex.so/join](https://codex.so/join) |[@codex_team](http://twitter.com/codex_team) | [@codex_team](http://instagram.com/codex_team) | 207 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@editorjs/embed", 3 | "version": "2.7.6", 4 | "keywords": [ 5 | "codex editor", 6 | "embed", 7 | "editor.js", 8 | "editorjs" 9 | ], 10 | "description": "Embed Tool for Editor.js", 11 | "license": "MIT", 12 | "repository": "https://github.com/editor-js/embed", 13 | "files": [ 14 | "dist" 15 | ], 16 | "main": "./dist/embed.umd.js", 17 | "module": "./dist/embed.mjs", 18 | "types": "dist/index.d.ts", 19 | "exports": { 20 | ".": { 21 | "import": "./dist/embed.mjs", 22 | "require": "./dist/embed.umd.js" 23 | } 24 | }, 25 | "scripts": { 26 | "dev": "vite", 27 | "build": "vite build", 28 | "test": "mocha --require ts-node/register --require ignore-styles --recursive './test/**/*.ts'", 29 | "lint": "eslint src/ --ext .js", 30 | "lint:errors": "eslint src/ --ext .js --quiet", 31 | "lint:fix": "eslint src/ --ext .js --fix" 32 | }, 33 | "author": { 34 | "name": "CodeX", 35 | "email": "team@codex.so" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.24.4", 39 | "@babel/plugin-transform-runtime": "^7.23.2", 40 | "@babel/preset-env": "^7.23.2", 41 | "@babel/register": "^7.22.15", 42 | "@types/chai": "^4.3.16", 43 | "@types/debounce": "^1.2.4", 44 | "@types/mocha": "^10.0.6", 45 | "@types/node": "^20.14.2", 46 | "chai": "^4.2.0", 47 | "debounce": "^1.2.0", 48 | "eslint": "^7.25.0", 49 | "eslint-config-codex": "^1.6.1", 50 | "ignore-styles": "^5.0.1", 51 | "mocha": "^7.1.1", 52 | "postcss-nested": "^4.2.1", 53 | "postcss-nested-ancestors": "^2.0.0", 54 | "ts-node": "^10.9.2", 55 | "typescript": "^5.4.5", 56 | "vite": "^4.5.0", 57 | "vite-plugin-css-injected-by-js": "^3.3.0", 58 | "vite-plugin-dts": "^3.9.1" 59 | }, 60 | "dependencies": { 61 | "@editorjs/editorjs": "^2.29.1" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-nested-ancestors'), 4 | require('postcss-nested'), 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | .embed-tool { 2 | &--loading { 3 | 4 | ^&__caption { 5 | display: none; 6 | } 7 | 8 | ^&__preloader { 9 | display: block; 10 | } 11 | 12 | ^&__content { 13 | display: none; 14 | } 15 | } 16 | 17 | &__preloader { 18 | display: none; 19 | position: relative; 20 | height: 200px; 21 | box-sizing: border-box; 22 | border-radius: 5px; 23 | border: 1px solid #e6e9eb; 24 | 25 | &::before { 26 | content: ''; 27 | position: absolute; 28 | z-index: 3; 29 | left: 50%; 30 | top: 50%; 31 | width: 30px; 32 | height: 30px; 33 | margin-top: -25px; 34 | margin-left: -15px; 35 | border-radius: 50%; 36 | border: 2px solid #cdd1e0; 37 | border-top-color: #388ae5; 38 | box-sizing: border-box; 39 | animation: embed-preloader-spin 2s infinite linear; 40 | } 41 | } 42 | 43 | &__url { 44 | position: absolute; 45 | bottom: 20px; 46 | left: 50%; 47 | transform: translateX(-50%); 48 | max-width: 250px; 49 | color: #7b7e89; 50 | font-size: 11px; 51 | white-space: nowrap; 52 | overflow: hidden; 53 | text-overflow: ellipsis; 54 | } 55 | 56 | &__content { 57 | width: 100%; 58 | } 59 | 60 | &__caption { 61 | margin-top: 7px; 62 | 63 | 64 | &[contentEditable=true][data-placeholder]::before{ 65 | position: absolute; 66 | content: attr(data-placeholder); 67 | color: #707684; 68 | font-weight: normal; 69 | opacity: 0; 70 | } 71 | 72 | &[contentEditable=true][data-placeholder]:empty { 73 | &::before { 74 | opacity: 1; 75 | } 76 | 77 | &:focus::before { 78 | opacity: 0; 79 | } 80 | } 81 | } 82 | } 83 | 84 | @keyframes embed-preloader-spin { 85 | 0% { 86 | transform: rotate(0deg); 87 | } 88 | 100% { 89 | transform: rotate(360deg); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import SERVICES from './services'; 2 | import './index.css'; 3 | import { debounce } from 'debounce'; 4 | import type { ServiceConfig, ServicesConfigType } from './serviceConfig'; 5 | import type { API , PatternPasteEventDetail } from '@editorjs/editorjs'; 6 | 7 | /** 8 | * @description Embed Tool data 9 | */ 10 | export interface EmbedData { 11 | /** Service name */ 12 | service: string; 13 | /** Source URL of embedded content */ 14 | source: string; 15 | /** URL to source embed page */ 16 | embed: string; 17 | /** Embedded content width */ 18 | width?: number; 19 | /** Embedded content height */ 20 | height?: number; 21 | /** Content caption */ 22 | caption?: string; 23 | } 24 | 25 | /** 26 | * @description Embed tool configuration object 27 | */ 28 | interface EmbedConfig { 29 | /** Additional services provided by user */ 30 | services?: ServicesConfigType; 31 | } 32 | 33 | /** 34 | * @description CSS object 35 | */ 36 | interface CSS { 37 | /** Base class for CSS */ 38 | baseClass: string; 39 | /** CSS class for input */ 40 | input: string; 41 | /** CSS class for container */ 42 | container: string; 43 | /** CSS class for loading container */ 44 | containerLoading: string; 45 | /** CSS class for preloader */ 46 | preloader: string; 47 | /** CSS class for caption */ 48 | caption: string; 49 | /** CSS class for URL */ 50 | url: string; 51 | /** CSS class for content */ 52 | content: string; 53 | } 54 | 55 | interface ConstructorArgs { 56 | // data — previously saved data 57 | data: EmbedData; 58 | // api - Editor.js API 59 | api: API; 60 | // readOnly - read-only mode flag 61 | readOnly: boolean; 62 | } 63 | 64 | /** 65 | * @class Embed 66 | * @classdesc Embed Tool for Editor.js 2.0 67 | * 68 | * @property {object} api - Editor.js API 69 | * @property {EmbedData} _data - private property with Embed data 70 | * @property {HTMLElement} element - embedded content container 71 | * 72 | * @property {object} services - static property with available services 73 | * @property {object} patterns - static property with patterns for paste handling configuration 74 | */ 75 | export default class Embed { 76 | /** Editor.js API */ 77 | private api: API; 78 | /** Private property with Embed data */ 79 | private _data: EmbedData; 80 | /** Embedded content container */ 81 | private element: HTMLElement | null; 82 | /** Read-only mode flag */ 83 | private readOnly: boolean; 84 | /** Static property with available services */ 85 | static services: { [key: string]: ServiceConfig }; 86 | /** Static property with patterns for paste handling configuration */ 87 | static patterns: { [key: string]: RegExp }; 88 | /** 89 | * @param {{data: EmbedData, config: EmbedConfig, api: object}} 90 | * data — previously saved data 91 | * config - user config for Tool 92 | * api - Editor.js API 93 | * readOnly - read-only mode flag 94 | */ 95 | constructor({ data, api, readOnly }: ConstructorArgs) { 96 | this.api = api; 97 | this._data = {} as EmbedData; 98 | this.element = null; 99 | this.readOnly = readOnly; 100 | 101 | this.data = data; 102 | } 103 | 104 | /** 105 | * @param {EmbedData} data - embed data 106 | * @param {RegExp} [data.regex] - pattern of source URLs 107 | * @param {string} [data.embedUrl] - URL scheme to embedded page. Use '<%= remote_id %>' to define a place to insert resource id 108 | * @param {string} [data.html] - iframe which contains embedded content 109 | * @param {number} [data.height] - iframe height 110 | * @param {number} [data.width] - iframe width 111 | * @param {string} [data.caption] - caption 112 | */ 113 | set data(data: EmbedData) { 114 | if (!(data instanceof Object)) { 115 | throw Error('Embed Tool data should be object'); 116 | } 117 | 118 | const { service, source, embed, width, height, caption = '' } = data; 119 | 120 | this._data = { 121 | service: service || this.data.service, 122 | source: source || this.data.source, 123 | embed: embed || this.data.embed, 124 | width: width || this.data.width, 125 | height: height || this.data.height, 126 | caption: caption || this.data.caption || '', 127 | }; 128 | 129 | const oldView = this.element; 130 | 131 | if (oldView) { 132 | oldView.parentNode?.replaceChild(this.render(), oldView); 133 | } 134 | } 135 | 136 | /** 137 | * @returns {EmbedData} 138 | */ 139 | get data(): EmbedData { 140 | if (this.element) { 141 | const caption = this.element.querySelector(`.${this.api.styles.input}`) as HTMLElement; 142 | 143 | this._data.caption = caption ? caption.innerHTML : ''; 144 | } 145 | 146 | return this._data; 147 | } 148 | 149 | /** 150 | * Get plugin styles 151 | * 152 | * @returns {object} 153 | */ 154 | get CSS(): CSS { 155 | return { 156 | baseClass: this.api.styles.block, 157 | input: this.api.styles.input, 158 | container: 'embed-tool', 159 | containerLoading: 'embed-tool--loading', 160 | preloader: 'embed-tool__preloader', 161 | caption: 'embed-tool__caption', 162 | url: 'embed-tool__url', 163 | content: 'embed-tool__content', 164 | }; 165 | } 166 | 167 | /** 168 | * Render Embed tool content 169 | * 170 | * @returns {HTMLElement} 171 | */ 172 | render(): HTMLElement { 173 | if (!this.data.service) { 174 | const container = document.createElement('div'); 175 | 176 | this.element = container; 177 | 178 | return container; 179 | } 180 | 181 | const { html } = Embed.services[this.data.service]; 182 | const container = document.createElement('div'); 183 | const caption = document.createElement('div'); 184 | const template = document.createElement('template'); 185 | const preloader = this.createPreloader(); 186 | 187 | container.classList.add(this.CSS.baseClass, this.CSS.container, this.CSS.containerLoading); 188 | caption.classList.add(this.CSS.input, this.CSS.caption); 189 | 190 | container.appendChild(preloader); 191 | 192 | caption.contentEditable = (!this.readOnly).toString(); 193 | caption.dataset.placeholder = this.api.i18n.t('Enter a caption'); 194 | caption.innerHTML = this.data.caption || ''; 195 | 196 | template.innerHTML = html; 197 | (template.content.firstChild as HTMLElement).setAttribute('src', this.data.embed); 198 | (template.content.firstChild as HTMLElement).classList.add(this.CSS.content); 199 | 200 | const embedIsReady = this.embedIsReady(container); 201 | 202 | if (template.content.firstChild) { 203 | container.appendChild(template.content.firstChild); 204 | } 205 | container.appendChild(caption); 206 | 207 | embedIsReady 208 | .then(() => { 209 | container.classList.remove(this.CSS.containerLoading); 210 | }); 211 | 212 | this.element = container; 213 | 214 | return container; 215 | } 216 | 217 | /** 218 | * Creates preloader to append to container while data is loading 219 | * 220 | * @returns {HTMLElement} 221 | */ 222 | createPreloader(): HTMLElement { 223 | const preloader = document.createElement('preloader'); 224 | const url = document.createElement('div'); 225 | 226 | url.textContent = this.data.source; 227 | 228 | preloader.classList.add(this.CSS.preloader); 229 | url.classList.add(this.CSS.url); 230 | 231 | preloader.appendChild(url); 232 | 233 | return preloader; 234 | } 235 | 236 | /** 237 | * Save current content and return EmbedData object 238 | * 239 | * @returns {EmbedData} 240 | */ 241 | save(): EmbedData { 242 | return this.data; 243 | } 244 | 245 | /** 246 | * Handle pasted url and return Service object 247 | * 248 | * @param {PasteEvent} event - event with pasted data 249 | */ 250 | onPaste(event: { detail: PatternPasteEventDetail }) { 251 | const { key: service, data: url } = event.detail; 252 | 253 | const { regex, embedUrl, width, height, id = (ids) => ids.shift() || '' } = Embed.services[service]; 254 | const result = regex.exec(url)?.slice(1); 255 | const embed = result ? embedUrl.replace(/<%= remote_id %>/g, id(result)) : ''; 256 | 257 | this.data = { 258 | service, 259 | source: url, 260 | embed, 261 | width, 262 | height, 263 | }; 264 | } 265 | 266 | /** 267 | * Analyze provided config and make object with services to use 268 | * 269 | * @param {EmbedConfig} config - configuration of embed block element 270 | */ 271 | static prepare({ config = {} } : {config: EmbedConfig}) { 272 | const { services = {} } = config; 273 | 274 | let entries = Object.entries(SERVICES); 275 | 276 | const enabledServices = Object 277 | .entries(services) 278 | .filter(([key, value]) => { 279 | return typeof value === 'boolean' && value === true; 280 | }) 281 | .map(([ key ]) => key); 282 | 283 | const userServices = Object 284 | .entries(services) 285 | .filter(([key, value]) => { 286 | return typeof value === 'object'; 287 | }) 288 | .filter(([key, service]) => Embed.checkServiceConfig(service as ServiceConfig)) 289 | .map(([key, service]) => { 290 | const { regex, embedUrl, html, height, width, id } = service as ServiceConfig; 291 | 292 | return [key, { 293 | regex, 294 | embedUrl, 295 | html, 296 | height, 297 | width, 298 | id, 299 | } ] as [string, ServiceConfig]; 300 | }); 301 | 302 | if (enabledServices.length) { 303 | entries = entries.filter(([ key ]) => enabledServices.includes(key)); 304 | } 305 | 306 | entries = entries.concat(userServices); 307 | 308 | Embed.services = entries.reduce<{ [key: string]: ServiceConfig }>((result, [key, service]) => { 309 | if (!(key in result)) { 310 | result[key] = service as ServiceConfig; 311 | 312 | return result; 313 | } 314 | 315 | result[key] = Object.assign({}, result[key], service); 316 | 317 | return result; 318 | }, {}); 319 | 320 | Embed.patterns = entries 321 | .reduce<{ [key: string]: RegExp }>((result, [key, item]) => { 322 | if (item && typeof item !== 'boolean') { 323 | result[key] = (item as ServiceConfig).regex as RegExp; 324 | } 325 | 326 | return result; 327 | }, {}); 328 | } 329 | 330 | /** 331 | * Check if Service config is valid 332 | * 333 | * @param {Service} config - configuration of embed block element 334 | * @returns {boolean} 335 | */ 336 | static checkServiceConfig(config: ServiceConfig): boolean { 337 | const { regex, embedUrl, html, height, width, id } = config; 338 | 339 | let isValid = Boolean(regex && regex instanceof RegExp) && 340 | Boolean(embedUrl && typeof embedUrl === 'string') && 341 | Boolean(html && typeof html === 'string'); 342 | 343 | isValid = isValid && (id !== undefined ? id instanceof Function : true); 344 | isValid = isValid && (height !== undefined ? Number.isFinite(height) : true); 345 | isValid = isValid && (width !== undefined ? Number.isFinite(width) : true); 346 | 347 | return isValid; 348 | } 349 | 350 | /** 351 | * Paste configuration to enable pasted URLs processing by Editor 352 | * 353 | * @returns {object} - object of patterns which contain regx for pasteConfig 354 | */ 355 | static get pasteConfig() { 356 | return { 357 | patterns: Embed.patterns, 358 | }; 359 | } 360 | 361 | /** 362 | * Notify core that read-only mode is supported 363 | * 364 | * @returns {boolean} 365 | */ 366 | static get isReadOnlySupported() { 367 | return true; 368 | } 369 | 370 | /** 371 | * Checks that mutations in DOM have finished after appending iframe content 372 | * 373 | * @param {HTMLElement} targetNode - HTML-element mutations of which to listen 374 | * @returns {Promise} - result that all mutations have finished 375 | */ 376 | embedIsReady(targetNode: HTMLElement): Promise { 377 | const PRELOADER_DELAY = 450; 378 | 379 | let observer: MutationObserver; 380 | 381 | return new Promise((resolve, reject) => { 382 | observer = new MutationObserver(debounce(resolve, PRELOADER_DELAY)); 383 | observer.observe(targetNode, { 384 | childList: true, 385 | subtree: true, 386 | }); 387 | }).then(() => { 388 | observer.disconnect(); 389 | }); 390 | } 391 | } 392 | -------------------------------------------------------------------------------- /src/serviceConfig.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @description Service configuration object 4 | */ 5 | export interface ServiceConfig { 6 | /** Pattern of source URLs */ 7 | regex: RegExp; 8 | /** URL scheme to embedded page. Use '<%= remote_id %>' to define a place to insert resource id */ 9 | embedUrl: string; 10 | /** Iframe which contains embedded content */ 11 | html: string; 12 | /** Function to get resource id from RegExp groups */ 13 | id?: (ids: string[]) => string; 14 | /** Embedded content width */ 15 | width?: number; 16 | /** Embedded content height */ 17 | height?: number; 18 | } 19 | 20 | /** 21 | * @description Type for services configuration 22 | */ 23 | export type ServicesConfigType = { [key: string]: ServiceConfig | boolean }; -------------------------------------------------------------------------------- /src/services.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-useless-escape */ 2 | import type { ServicesConfigType } from './serviceConfig'; 3 | 4 | const SERVICES: ServicesConfigType = { 5 | vimeo: { 6 | regex: /(?:http[s]?:\/\/)?(?:www.)?(?:player.)?vimeo\.co(?:.+\/([^\/]\d+)(?:#t=[\d]+)?s?$)/, 7 | embedUrl: 'https://player.vimeo.com/video/<%= remote_id %>?title=0&byline=0', 8 | html: '', 9 | height: 320, 10 | width: 580, 11 | }, 12 | youtube: { 13 | regex: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/, 14 | embedUrl: 'https://www.youtube.com/embed/<%= remote_id %>', 15 | html: '', 16 | height: 320, 17 | width: 580, 18 | id: ([id, params]) => { 19 | if (!params && id) { 20 | return id; 21 | } 22 | 23 | const paramsMap: Record = { 24 | start: 'start', 25 | end: 'end', 26 | t: 'start', 27 | // eslint-disable-next-line camelcase 28 | time_continue: 'start', 29 | list: 'list', 30 | }; 31 | 32 | let newParams = params.slice(1) 33 | .split('&') 34 | .map(param => { 35 | const [name, value] = param.split('='); 36 | 37 | if (!id && name === 'v') { 38 | id = value; 39 | 40 | return null; 41 | } 42 | 43 | if (!(paramsMap[name])) { 44 | return null; 45 | } 46 | 47 | if (value === 'LL' || 48 | value.startsWith('RDMM') || 49 | value.startsWith('FL')) { 50 | return null; 51 | } 52 | 53 | return `${paramsMap[name]}=${value}`; 54 | }) 55 | .filter(param => !!param); 56 | 57 | return id + '?' + newParams.join('&'); 58 | }, 59 | }, 60 | coub: { 61 | regex: /https?:\/\/coub\.com\/view\/([^\/\?\&]+)/, 62 | embedUrl: 'https://coub.com/embed/<%= remote_id %>', 63 | html: '', 64 | height: 320, 65 | width: 580, 66 | }, 67 | vine: { 68 | regex: /https?:\/\/vine\.co\/v\/([^\/\?\&]+)/, 69 | embedUrl: 'https://vine.co/v/<%= remote_id %>/embed/simple/', 70 | html: '', 71 | height: 320, 72 | width: 580, 73 | }, 74 | imgur: { 75 | regex: /https?:\/\/(?:i\.)?imgur\.com.*\/([a-zA-Z0-9]+)(?:\.gifv)?/, 76 | embedUrl: 'http://imgur.com/<%= remote_id %>/embed', 77 | html: '', 78 | height: 500, 79 | width: 540, 80 | }, 81 | gfycat: { 82 | regex: /https?:\/\/gfycat\.com(?:\/detail)?\/([a-zA-Z]+)/, 83 | embedUrl: 'https://gfycat.com/ifr/<%= remote_id %>', 84 | html: "", 85 | height: 436, 86 | width: 580, 87 | }, 88 | 'twitch-channel': { 89 | regex: /https?:\/\/www\.twitch\.tv\/([^\/\?\&]*)\/?$/, 90 | embedUrl: 'https://player.twitch.tv/?channel=<%= remote_id %>', 91 | html: '', 92 | height: 366, 93 | width: 600, 94 | }, 95 | 'twitch-video': { 96 | regex: /https?:\/\/www\.twitch\.tv\/(?:[^\/\?\&]*\/v|videos)\/([0-9]*)/, 97 | embedUrl: 'https://player.twitch.tv/?video=v<%= remote_id %>', 98 | html: '', 99 | height: 366, 100 | width: 600, 101 | }, 102 | 'yandex-music-album': { 103 | regex: /https?:\/\/music\.yandex\.ru\/album\/([0-9]*)\/?$/, 104 | embedUrl: 'https://music\.yandex\.ru/iframe/#album/<%= remote_id %>/', 105 | html: '', 106 | height: 400, 107 | width: 540, 108 | }, 109 | 'yandex-music-track': { 110 | regex: /https?:\/\/music\.yandex\.ru\/album\/([0-9]*)\/track\/([0-9]*)/, 111 | embedUrl: 'https://music\.yandex\.ru/iframe/#track/<%= remote_id %>/', 112 | html: '', 113 | height: 100, 114 | width: 540, 115 | id: (ids) => ids.join('/'), 116 | }, 117 | 'yandex-music-playlist': { 118 | regex: /https?:\/\/music\.yandex\.ru\/users\/([^\/\?\&]*)\/playlists\/([0-9]*)/, 119 | embedUrl: 'https://music\.yandex\.ru/iframe/#playlist/<%= remote_id %>/show/cover/description/', 120 | html: '', 121 | height: 400, 122 | width: 540, 123 | id: (ids) => ids.join('/'), 124 | }, 125 | codepen: { 126 | regex: /https?:\/\/codepen\.io\/([^\/\?\&]*)\/pen\/([^\/\?\&]*)/, 127 | embedUrl: 'https://codepen.io/<%= remote_id %>?height=300&theme-id=0&default-tab=css,result&embed-version=2', 128 | html: "", 129 | height: 300, 130 | width: 600, 131 | id: (ids) => ids.join('/embed/'), 132 | }, 133 | instagram: { 134 | //it support both reel and post 135 | regex: /^https:\/\/(?:www\.)?instagram\.com\/(?:reel|p)\/(.*)/, 136 | embedUrl: 'https://www.instagram.com/p/<%= remote_id %>/embed', 137 | html: '', 138 | height: 505, 139 | width: 400, 140 | id: (groups: string[]) => groups?.[0]?.split("/")[0], 141 | }, 142 | twitter: { 143 | regex: /^https?:\/\/(www\.)?(?:twitter\.com|x\.com)\/.+\/status\/(\d+)/, 144 | embedUrl: 'https://platform.twitter.com/embed/Tweet.html?id=<%= remote_id %>', 145 | html: '', 146 | height: 300, 147 | width: 600, 148 | id: ids => ids[1], 149 | }, 150 | pinterest: { 151 | regex: /https?:\/\/([^\/\?\&]*).pinterest.com\/pin\/([^\/\?\&]*)\/?$/, 152 | embedUrl: 'https://assets.pinterest.com/ext/embed.html?id=<%= remote_id %>', 153 | html: "", 154 | id: (ids) => { 155 | return ids[1]; 156 | }, 157 | }, 158 | facebook: { 159 | regex: /https?:\/\/www.facebook.com\/([^\/\?\&]*)\/(.*)/, 160 | embedUrl: 'https://www.facebook.com/plugins/post.php?href=https://www.facebook.com/<%= remote_id %>&width=500', 161 | html: "", 162 | id: (ids) => { 163 | return ids.join('/'); 164 | }, 165 | }, 166 | aparat: { 167 | regex: /(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/, 168 | embedUrl: 'https://www.aparat.com/video/video/embed/videohash/<%= remote_id %>/vt/frame', 169 | html: '', 170 | height: 300, 171 | width: 600, 172 | }, 173 | miro: { 174 | regex: /https:\/\/miro.com\/\S+(\S{12})\/(\S+)?/, 175 | embedUrl: 'https://miro.com/app/live-embed/<%= remote_id %>', 176 | html: '', 177 | }, 178 | github: { 179 | regex: /https?:\/\/gist.github.com\/([^\/\?\&]*)\/([^\/\?\&]*)/, 180 | embedUrl: 'data:text/html;charset=utf-8,', 181 | html: '', 182 | height: 300, 183 | width: 600, 184 | id: (groups) => `${groups.join('/')}.js`, 185 | }, 186 | }; 187 | 188 | export default SERVICES; 189 | -------------------------------------------------------------------------------- /test/services.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import EmbedTool, { EmbedData } from '../src/index'; 4 | import { API } from '@editorjs/editorjs'; 5 | 6 | EmbedTool.prepare({config: {}}); 7 | const { patterns } = EmbedTool.pasteConfig; 8 | const embed = new EmbedTool({data: {} as EmbedData, api: {} as API, readOnly: false}); 9 | 10 | const composePasteEventMock = (type: string, service: string, url: string) => ({ 11 | type, 12 | detail: { 13 | key: service, 14 | data: url 15 | } 16 | }); 17 | 18 | describe('Services Regexps', () => { 19 | it('YouTube', async () => { 20 | const service = 'youtube'; 21 | 22 | const urls = [ 23 | { source: 'https://www.youtube.com/watch?v=wZZ7oFKsKzY&t=120', embed: 'https://www.youtube.com/embed/wZZ7oFKsKzY?start=120' }, 24 | { source: 'https://www.youtube.com/embed/_q51LZ2HpbE?list=PLLy6qvPKpdlV3OAw00EuZMoYPz4pYuwuN', embed: 'https://www.youtube.com/embed/_q51LZ2HpbE?list=PLLy6qvPKpdlV3OAw00EuZMoYPz4pYuwuN' }, 25 | { source: 'https://www.youtube.com/watch?time_continue=173&v=Nd9LbCWpHp8', embed: 'https://www.youtube.com/embed/Nd9LbCWpHp8?start=173' }, 26 | { source: 'https://www.youtube.com/watch?v=efBBjIK3b8I&list=LL&t=1337', embed: 'https://www.youtube.com/embed/efBBjIK3b8I?start=1337' }, 27 | { source: 'https://www.youtube.com/watch?v=yQUeAin7fII&list=RDMMnMXCzscqi_M', embed: 'https://www.youtube.com/embed/yQUeAin7fII?' }, 28 | { source: 'https://www.youtube.com/watch?v=3kw2sttGXMI&list=FLgc4xqIMDoiP4KOTFS21TJA', embed: 'https://www.youtube.com/embed/3kw2sttGXMI?' }, 29 | ]; 30 | 31 | urls.forEach(url => { 32 | expect(patterns[service].test(url.source)).to.be.true; 33 | 34 | const event = composePasteEventMock('pattern', service, url.source); 35 | 36 | embed.onPaste(event); 37 | 38 | expect(embed.data.service).to.be.equal(service); 39 | expect(embed.data.embed).to.be.equal(url.embed); 40 | expect(embed.data.source).to.be.equal(url.source); 41 | }); 42 | }); 43 | 44 | it('Vimeo', async () => { 45 | const service = 'vimeo'; 46 | 47 | const urls = [ 48 | { source: 'https://vimeo.com/289836809', embed: 'https://player.vimeo.com/video/289836809?title=0&byline=0' }, 49 | { source: 'https://www.vimeo.com/280712228', embed: 'https://player.vimeo.com/video/280712228?title=0&byline=0' }, 50 | { source: 'https://player.vimeo.com/video/504749530', embed: 'https://player.vimeo.com/video/504749530?title=0&byline=0' } 51 | ]; 52 | 53 | urls.forEach(url => { 54 | expect(patterns[service].test(url.source)).to.be.true; 55 | 56 | const event = composePasteEventMock('pattern', service, url.source); 57 | 58 | embed.onPaste(event); 59 | 60 | expect(embed.data.service).to.be.equal(service); 61 | expect(embed.data.embed).to.be.equal(url.embed); 62 | expect(embed.data.source).to.be.equal(url.source); 63 | }); 64 | }); 65 | 66 | it('Coub', async () => { 67 | const service = 'coub'; 68 | 69 | const urls = [ 70 | { source: 'https://coub.com/view/1efrxs', embed: 'https://coub.com/embed/1efrxs' }, 71 | { source: 'https://coub.com/view/1c6nrr', embed: 'https://coub.com/embed/1c6nrr' } 72 | ]; 73 | 74 | urls.forEach(url => { 75 | expect(patterns[service].test(url.source)).to.be.true; 76 | const event = composePasteEventMock('pattern', service, url.source); 77 | 78 | embed.onPaste(event); 79 | 80 | expect(embed.data.service).to.be.equal(service); 81 | expect(embed.data.embed).to.be.equal(url.embed); 82 | expect(embed.data.source).to.be.equal(url.source); 83 | }); 84 | }); 85 | 86 | it('Imgur', async () => { 87 | const service = 'imgur'; 88 | 89 | const urls = [ 90 | { source: 'https://imgur.com/gallery/OHbkxgr', embed: 'http://imgur.com/OHbkxgr/embed' }, 91 | { source: 'https://imgur.com/gallery/TqIWG12', embed: 'http://imgur.com/TqIWG12/embed' } 92 | ]; 93 | 94 | urls.forEach(url => { 95 | expect(patterns[service].test(url.source)).to.be.true; 96 | 97 | const event = composePasteEventMock('pattern', service, url.source); 98 | 99 | embed.onPaste(event); 100 | 101 | expect(embed.data.service).to.be.equal(service); 102 | expect(embed.data.embed).to.be.equal(url.embed); 103 | expect(embed.data.source).to.be.equal(url.source); 104 | }); 105 | }); 106 | 107 | it('Gfycat', async () => { 108 | const service = 'gfycat'; 109 | 110 | const urls = [ 111 | { source: 'https://gfycat.com/EsteemedMarvelousHagfish', embed: 'https://gfycat.com/ifr/EsteemedMarvelousHagfish' }, 112 | { source: 'https://gfycat.com/OddCornyLeech', embed: 'https://gfycat.com/ifr/OddCornyLeech' } 113 | ]; 114 | 115 | urls.forEach(url => { 116 | expect(patterns[service].test(url.source)).to.be.true; 117 | const event = composePasteEventMock('pattern', service, url.source); 118 | 119 | embed.onPaste(event); 120 | 121 | expect(embed.data.service).to.be.equal(service); 122 | expect(embed.data.embed).to.be.equal(url.embed); 123 | expect(embed.data.source).to.be.equal(url.source); 124 | }); 125 | }); 126 | 127 | it('Twitch channel', async () => { 128 | const service = 'twitch-channel'; 129 | 130 | const urls = [ 131 | { source: 'https://www.twitch.tv/ninja', embed: 'https://player.twitch.tv/?channel=ninja' }, 132 | { source: 'https://www.twitch.tv/gohamedia', embed: 'https://player.twitch.tv/?channel=gohamedia' } 133 | ]; 134 | 135 | urls.forEach(url => { 136 | expect(patterns[service].test(url.source)).to.be.true; 137 | const event = composePasteEventMock('pattern', service, url.source); 138 | 139 | embed.onPaste(event); 140 | 141 | expect(embed.data.service).to.be.equal(service); 142 | expect(embed.data.embed).to.be.equal(url.embed); 143 | expect(embed.data.source).to.be.equal(url.source); 144 | }); 145 | }); 146 | 147 | it('Twitch video', async () => { 148 | const service = 'twitch-video'; 149 | 150 | const urls = [ 151 | { source: 'https://www.twitch.tv/videos/315468440', embed: 'https://player.twitch.tv/?video=v315468440' }, 152 | { source: 'https://www.twitch.tv/videos/314691366', embed: 'https://player.twitch.tv/?video=v314691366' } 153 | ]; 154 | 155 | urls.forEach(url => { 156 | expect(patterns[service].test(url.source)).to.be.true; 157 | 158 | const event = composePasteEventMock('pattern', service, url.source); 159 | 160 | embed.onPaste(event); 161 | 162 | expect(embed.data.service).to.be.equal(service); 163 | expect(embed.data.embed).to.be.equal(url.embed); 164 | expect(embed.data.source).to.be.equal(url.source); 165 | }); 166 | }); 167 | 168 | it('Yandex Music album', async () => { 169 | const service = 'yandex-music-album'; 170 | 171 | const urls = [ 172 | { source: 'https://music.yandex.ru/album/5643859', embed: 'https://music.yandex.ru/iframe/#album/5643859/' }, 173 | { source: 'https://music.yandex.ru/album/5393158', embed: 'https://music.yandex.ru/iframe/#album/5393158/' } 174 | ]; 175 | 176 | urls.forEach(url => { 177 | expect(patterns[service].test(url.source)).to.be.true; 178 | 179 | const event = composePasteEventMock('pattern', service, url.source); 180 | 181 | embed.onPaste(event); 182 | 183 | expect(embed.data.service).to.be.equal(service); 184 | expect(embed.data.embed).to.be.equal(url.embed); 185 | expect(embed.data.source).to.be.equal(url.source); 186 | }); 187 | }); 188 | 189 | it('Yandex Music track', async () => { 190 | const service = 'yandex-music-track'; 191 | 192 | const urls = [ 193 | { source: 'https://music.yandex.ru/album/5643859/track/42662275', embed: 'https://music.yandex.ru/iframe/#track/5643859/42662275/' }, 194 | { source: 'https://music.yandex.ru/album/5393158/track/41249158', embed: 'https://music.yandex.ru/iframe/#track/5393158/41249158/' } 195 | ]; 196 | 197 | urls.forEach(url => { 198 | expect(patterns[service].test(url.source)).to.be.true; 199 | 200 | const event = composePasteEventMock('pattern', service, url.source); 201 | 202 | embed.onPaste(event); 203 | 204 | expect(embed.data.service).to.be.equal(service); 205 | expect(embed.data.embed).to.be.equal(url.embed); 206 | expect(embed.data.source).to.be.equal(url.source); 207 | }); 208 | }); 209 | 210 | it('Yandex Music playlist', async () => { 211 | const service = 'yandex-music-playlist'; 212 | 213 | const urls = [ 214 | { source: 'https://music.yandex.ru/users/yamusic-personal/playlists/25098905', embed: 'https://music.yandex.ru/iframe/#playlist/yamusic-personal/25098905/show/cover/description/' }, 215 | { source: 'https://music.yandex.ru/users/yamusic-personal/playlists/27924603', embed: 'https://music.yandex.ru/iframe/#playlist/yamusic-personal/27924603/show/cover/description/' } 216 | ]; 217 | 218 | urls.forEach(url => { 219 | expect(patterns[service].test(url.source)).to.be.true; 220 | const event = composePasteEventMock('pattern', service, url.source); 221 | 222 | embed.onPaste(event); 223 | 224 | expect(embed.data.service).to.be.equal(service); 225 | expect(embed.data.embed).to.be.equal(url.embed); 226 | expect(embed.data.source).to.be.equal(url.source); 227 | }); 228 | }); 229 | 230 | it('Codepen', async () => { 231 | const service = 'codepen'; 232 | 233 | const urls = [ 234 | { source: 'https://codepen.io/Rikkokiri/pen/RYBrwG', embed: 'https://codepen.io/Rikkokiri/embed/RYBrwG?height=300&theme-id=0&default-tab=css,result&embed-version=2' }, 235 | { source: 'https://codepen.io/geoffgraham/pen/bxEVEN', embed: 'https://codepen.io/geoffgraham/embed/bxEVEN?height=300&theme-id=0&default-tab=css,result&embed-version=2' } 236 | ]; 237 | 238 | urls.forEach(url => { 239 | expect(patterns[service].test(url.source)).to.be.true; 240 | 241 | const event = composePasteEventMock('pattern', service, url.source); 242 | 243 | embed.onPaste(event); 244 | 245 | expect(embed.data.service).to.be.equal(service); 246 | expect(embed.data.embed).to.be.equal(url.embed); 247 | expect(embed.data.source).to.be.equal(url.source); 248 | }); 249 | }); 250 | 251 | it('Twitter', async () => { 252 | const service = 'twitter'; 253 | 254 | const urls = [ 255 | { 256 | source: 'https://twitter.com/codex_team/status/1202295536826630145', 257 | embed: 'https://platform.twitter.com/embed/Tweet.html?id=1202295536826630145' 258 | }, 259 | { 260 | source: 'https://twitter.com/codex_team/status/1202295536826630145?s=20&t=wrY8ei5GBjbbmNonrEm2kQ', 261 | embed: 'https://platform.twitter.com/embed/Tweet.html?id=1202295536826630145' 262 | }, 263 | { 264 | source: 'https://x.com/codex_team/status/1202295536826630145', 265 | embed: 'https://platform.twitter.com/embed/Tweet.html?id=1202295536826630145' 266 | }, 267 | ]; 268 | 269 | urls.forEach(url => { 270 | expect(patterns[service].test(url.source)).to.be.true; 271 | 272 | const event = composePasteEventMock('pattern', service, url.source); 273 | 274 | embed.onPaste(event); 275 | 276 | expect(embed.data.service).to.be.equal(service); 277 | expect(embed.data.embed).to.be.equal(url.embed); 278 | expect(embed.data.source).to.be.equal(url.source); 279 | }); 280 | }); 281 | 282 | it('Instagram', async () => { 283 | const service = 'instagram'; 284 | 285 | const urls = [ 286 | { 287 | source: 'https://www.instagram.com/p/B--iRCFHVxI/', 288 | embed: 'https://www.instagram.com/p/B--iRCFHVxI/embed' 289 | }, 290 | { 291 | source: 'https://www.instagram.com/p/CfQzzGNphD8/?utm_source=ig_web_copy_link', 292 | embed: 'https://www.instagram.com/p/CfQzzGNphD8/embed' 293 | }, 294 | { 295 | source: 'https://www.instagram.com/p/C4_Lsf1NBra/?img_index=1', 296 | embed: 'https://www.instagram.com/p/C4_Lsf1NBra/embed' 297 | }, 298 | { 299 | source: 'https://www.instagram.com/p/C5ZZUWPydSY/?utm_source=ig_web_copy_link', 300 | embed: 'https://www.instagram.com/p/C5ZZUWPydSY/embed' 301 | }, 302 | { 303 | source: 'https://www.instagram.com/reel/C19IuqJx6wm/', 304 | embed: 'https://www.instagram.com/p/C19IuqJx6wm/embed' 305 | }, 306 | { 307 | source: 'https://www.instagram.com/reel/C19IuqJx6wm/?utm_source=ig_web_copy_link', 308 | embed: 'https://www.instagram.com/p/C19IuqJx6wm/embed' 309 | }, 310 | 311 | ]; 312 | 313 | urls.forEach(url => { 314 | expect(patterns[service].test(url.source)).to.be.true; 315 | 316 | const event = composePasteEventMock('pattern', service, url.source); 317 | 318 | embed.onPaste(event); 319 | 320 | expect(embed.data.service).to.be.equal(service); 321 | expect(embed.data.embed).to.be.equal(url.embed); 322 | expect(embed.data.source).to.be.equal(url.source); 323 | }); 324 | }); 325 | it('Aparat', async () => { 326 | const service = 'aparat'; 327 | const urls = [ 328 | { 329 | source: 'https://www.aparat.com/v/tDZe5', 330 | embed: 'https://www.aparat.com/video/video/embed/videohash/tDZe5/vt/frame' 331 | }, 332 | ]; 333 | 334 | urls.forEach(url => { 335 | expect(patterns[service].test(url.source)).to.be.true; 336 | 337 | const event = composePasteEventMock('pattern', service, url.source); 338 | 339 | embed.onPaste(event); 340 | 341 | expect(embed.data.service).to.be.equal(service); 342 | expect(embed.data.embed).to.be.equal(url.embed); 343 | expect(embed.data.source).to.be.equal(url.source); 344 | }); 345 | }); 346 | 347 | it('Patterns', async () => { 348 | const services = { 349 | youtube: 'https://www.youtube.com/watch?v=wZZ7oFKsKzY', 350 | vimeo: 'https://vimeo.com/289836809', 351 | coub: 'https://coub.com/view/1efrxs', 352 | imgur: 'https://imgur.com/gallery/OHbkxgr', 353 | gfycat: 'https://gfycat.com/EsteemedMarvelousHagfish', 354 | 'twitch-channel': 'https://www.twitch.tv/ninja', 355 | 'twitch-video': 'https://www.twitch.tv/videos/315468440', 356 | 'yandex-music-album': 'https://music.yandex.ru/album/5643859', 357 | 'yandex-music-track': 'https://music.yandex.ru/album/5643859/track/42662275', 358 | 'yandex-music-playlist': 'https://music.yandex.ru/users/yamusic-personal/playlists/25098905', 359 | 'codepen': 'https://codepen.io/Rikkokiri/pen/RYBrwG' 360 | }; 361 | 362 | Object 363 | .entries(services) 364 | .forEach(([name, url]) => { 365 | const foundService = Object.entries(patterns).find(([key, pattern]) => { 366 | return pattern.test(url); 367 | }); 368 | 369 | expect(foundService![0]).to.be.equal(name); 370 | }); 371 | }); 372 | 373 | 374 | it('Pinterest', async () => { 375 | const service = 'pinterest'; 376 | 377 | const urls = [ 378 | { 379 | source: 'https://tr.pinterest.com/pin/409757266103637553/', 380 | embed: 'https://assets.pinterest.com/ext/embed.html?id=409757266103637553' 381 | }, 382 | ]; 383 | 384 | urls.forEach(url => { 385 | expect(patterns[service].test(url.source)).to.be.true; 386 | 387 | const event = composePasteEventMock('pattern', service, url.source); 388 | 389 | embed.onPaste(event); 390 | 391 | expect(embed.data.service).to.be.equal(service); 392 | expect(embed.data.embed).to.be.equal(url.embed); 393 | expect(embed.data.source).to.be.equal(url.source); 394 | }); 395 | }); 396 | 397 | it('Facebook', async () => { 398 | const service = 'facebook'; 399 | 400 | const urls = [ 401 | { 402 | source: 'https://www.facebook.com/genclikforeverresmi/videos/944647522284479', 403 | embed: 'https://www.facebook.com/plugins/post.php?href=https://www.facebook.com/genclikforeverresmi/videos/944647522284479&width=500' 404 | }, 405 | { 406 | source:'https://www.facebook.com/0devco/posts/497515624410920', 407 | embed: 'https://www.facebook.com/plugins/post.php?href=https://www.facebook.com/0devco/posts/497515624410920&width=500' 408 | } 409 | ]; 410 | 411 | urls.forEach(url => { 412 | expect(patterns[service].test(url.source)).to.be.true; 413 | 414 | const event = composePasteEventMock('pattern', service, url.source); 415 | 416 | embed.onPaste(event); 417 | 418 | expect(embed.data.service).to.be.equal(service); 419 | expect(embed.data.embed).to.be.equal(url.embed); 420 | expect(embed.data.source).to.be.equal(url.source); 421 | }); 422 | }); 423 | 424 | it('Github', async () => { 425 | const service = 'github'; 426 | 427 | const urls = [ 428 | { 429 | source: 'https://gist.github.com/userharis/091b56505c804276e1f91925976f11db', 430 | embed: 'data:text/html;charset=utf-8,', 431 | }, 432 | { 433 | source: 'https://gist.github.com/userharis/a8c2977094d4716c43e35e6c20b7d306', 434 | embed: 'data:text/html;charset=utf-8,', 435 | }, 436 | ]; 437 | 438 | urls.forEach(url => { 439 | expect(patterns[service].test(url.source)).to.be.true; 440 | 441 | const event = composePasteEventMock('pattern', service, url.source); 442 | 443 | embed.onPaste(event); 444 | 445 | expect(embed.data.service).to.be.equal(service); 446 | expect(embed.data.embed).to.be.equal(url.embed); 447 | expect(embed.data.source).to.be.equal(url.source); 448 | }); 449 | }); 450 | }); 451 | 452 | describe('Miro service', () => { 453 | it('should correctly parse URL got from a browser', () => { 454 | const regularBoardUrl = 'https://miro.com/app/board/10J_kw57KxQ=/'; 455 | const event = composePasteEventMock('pattern', 'miro', regularBoardUrl); 456 | 457 | embed.onPaste(event); 458 | 459 | expect(patterns.miro.test(regularBoardUrl)).to.be.true; 460 | expect(embed.data.service).to.be.equal('miro'); 461 | expect(embed.data.embed).to.be.equal('https://miro.com/app/live-embed/10J_kw57KxQ='); 462 | expect(embed.data.source).to.be.equal(regularBoardUrl); 463 | }) 464 | }); 465 | 466 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Language and Environment */ 4 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 5 | /* Modules */ 6 | "module": "CommonJS", /* Specify what module code is generated. */ 7 | "typeRoots": ["./node_modules/@types", "./types"], /* Specify multiple folders that act like './node_modules/@types'. */ 8 | /* Interop Constraints */ 9 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 10 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 11 | /* Type Checking */ 12 | "strict": true, /* Enable all strict type-checking options. */ 13 | }, 14 | "include": ["src/*"] 15 | } -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; 3 | import * as pkg from "./package.json"; 4 | import dts from 'vite-plugin-dts'; 5 | 6 | const NODE_ENV = process.argv.mode || "development"; 7 | const VERSION = pkg.version; 8 | 9 | export default { 10 | build: { 11 | copyPublicDir: false, 12 | lib: { 13 | entry: path.resolve(__dirname, "src", "index.ts"), 14 | name: "Embed", 15 | fileName: "embed", 16 | }, 17 | }, 18 | define: { 19 | NODE_ENV: JSON.stringify(NODE_ENV), 20 | VERSION: JSON.stringify(VERSION), 21 | }, 22 | 23 | plugins: [cssInjectedByJsPlugin(), 24 | dts({ 25 | tsconfigPath: './tsconfig.json' 26 | }) 27 | ], 28 | }; 29 | --------------------------------------------------------------------------------