├── .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 | 
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 |
--------------------------------------------------------------------------------