Repository: appleple/react-modal-video
Branch: master
Commit: 237250e30c26
Files: 16
Total size: 39.6 KB
Directory structure:
gitextract_so3v8z3u/
├── .editorconfig
├── .eslintrc
├── .gitignore
├── .node-version
├── LICENSE
├── babel.config.js
├── circle.yml
├── css/
│ └── modal-video.css
├── lib/
│ └── index.js
├── nodemon.json
├── package.json
├── readme.md
├── scss/
│ └── modal-video.scss
├── src/
│ └── index.jsx
└── test/
├── index.html
└── src/
└── index.jsx
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
# editorconfig.org
root = true
[*.js]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.html]
# すべてのファイルに適用する
charset = utf-8
# 文字コードを統一
indent_style = tab
#インデントを統一する。「tab」か「 space」
indent_size = 2
# インデントの数を統一
trim_trailing_whitespace = true
# 行末のホワイトスペースを削除
insert_final_newline = true
# フォルダの最後の行に改行
end_of_line = lf
================================================
FILE: .eslintrc
================================================
{
"env": {
"browser": true
},
"globals": {
"document": true,
"window": true
},
"rules":{
"comma-dangle":0
},
"parserOptions": {
"sourceType": "module",
"ecmaVersion": 2015,
"ecmaFeatures": {
"jsx": true
}
}
}
================================================
FILE: .gitignore
================================================
node_modules/*
yarn.lock
.DS_Store
.idea
test/dist
================================================
FILE: .node-version
================================================
18.12.1
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2016 appleple
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: babel.config.js
================================================
module.exports = {
presets: [
['@babel/preset-env',
{
targets: {
ie: 11,
},
useBuiltIns: 'usage',
corejs: 3,
},
],
['@babel/preset-react'],
]
};
================================================
FILE: circle.yml
================================================
machine:
node:
version: 6.2.0
dependencies:
override:
- "npm install"
test:
override:
- "npm run test"
================================================
FILE: css/modal-video.css
================================================
@keyframes modal-video {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-video-inner {
from {
transform: translate(0, 100px);
}
to {
transform: translate(0, 0);
}
}
.modal-video {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000000;
cursor: pointer;
opacity: 1;
animation-timing-function: ease-out;
animation-duration: 0.3s;
animation-name: modal-video;
-webkit-transition: opacity 0.3s ease-out;
-moz-transition: opacity 0.3s ease-out;
-ms-transition: opacity 0.3s ease-out;
-o-transition: opacity 0.3s ease-out;
transition: opacity 0.3s ease-out;
}
.modal-video-effect-exit {
opacity: 0;
}
.modal-video-effect-exit .modal-video-movie-wrap {
-webkit-transform: translate(0, 100px);
-moz-transform: translate(0, 100px);
-ms-transform: translate(0, 100px);
-o-transform: translate(0, 100px);
transform: translate(0, 100px);
}
.modal-video-body {
max-width: 960px;
width: 100%;
height: 100%;
margin: 0 auto;
padding: 0 10px;
display: flex;
justify-content: center;
box-sizing: border-box;
}
.modal-video-inner {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
@media (orientation: landscape) {
.modal-video-inner {
padding: 10px 60px;
box-sizing: border-box;
}
}
.modal-video-movie-wrap {
width: 100%;
height: 0;
position: relative;
padding-bottom: 56.25%;
background-color: #333;
animation-timing-function: ease-out;
animation-duration: 0.3s;
animation-name: modal-video-inner;
-webkit-transform: translate(0, 0);
-moz-transform: translate(0, 0);
-ms-transform: translate(0, 0);
-o-transform: translate(0, 0);
transform: translate(0, 0);
-webkit-transition: -webkit-transform 0.3s ease-out;
-moz-transition: -moz-transform 0.3s ease-out;
-ms-transition: -ms-transform 0.3s ease-out;
-o-transition: -o-transform 0.3s ease-out;
transition: transform 0.3s ease-out;
}
.modal-video-movie-wrap iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.modal-video-close-btn {
position: absolute;
z-index: 2;
top: -45px;
right: 0px;
display: inline-block;
width: 35px;
height: 35px;
overflow: hidden;
border: none;
background: transparent;
}
@media (orientation: landscape) {
.modal-video-close-btn {
top: 0;
right: -45px;
}
}
.modal-video-close-btn:before {
transform: rotate(45deg);
}
.modal-video-close-btn:after {
transform: rotate(-45deg);
}
.modal-video-close-btn:before, .modal-video-close-btn:after {
content: "";
position: absolute;
height: 2px;
width: 100%;
top: 50%;
left: 0;
margin-top: -1px;
background: #fff;
border-radius: 5px;
margin-top: -6px;
}
================================================
FILE: lib/index.js
================================================
"use strict";
function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
require("core-js/modules/es.object.to-string.js");
require("core-js/modules/es.reflect.construct.js");
require("core-js/modules/es.symbol.to-primitive.js");
require("core-js/modules/es.date.to-primitive.js");
require("core-js/modules/es.symbol.js");
require("core-js/modules/es.symbol.description.js");
require("core-js/modules/es.symbol.iterator.js");
require("core-js/modules/es.array.iterator.js");
require("core-js/modules/es.string.iterator.js");
require("core-js/modules/web.dom-collections.iterator.js");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
require("core-js/modules/es.array.concat.js");
require("core-js/modules/es.number.constructor.js");
require("core-js/modules/es.object.get-prototype-of.js");
var _react = _interopRequireDefault(require("react"));
var _CSSTransition = _interopRequireDefault(require("react-transition-group/CSSTransition"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } }
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); }
function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); Object.defineProperty(subClass, "prototype", { writable: false }); if (superClass) _setPrototypeOf(subClass, superClass); }
function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf ? Object.setPrototypeOf.bind() : function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }
function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }
function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); }
function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }
function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } }
function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf.bind() : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }
var ModalVideo = /*#__PURE__*/function (_React$Component) {
_inherits(ModalVideo, _React$Component);
var _super = _createSuper(ModalVideo);
function ModalVideo(props) {
var _this;
_classCallCheck(this, ModalVideo);
_this = _super.call(this, props);
_this.state = {
isOpen: false,
modalVideoWidth: '100%'
};
_this.closeModal = _this.closeModal.bind(_assertThisInitialized(_this));
_this.updateFocus = _this.updateFocus.bind(_assertThisInitialized(_this));
_this.timeout; // used for resizing video.
return _this;
}
_createClass(ModalVideo, [{
key: "openModal",
value: function openModal() {
this.setState({
isOpen: true
});
}
}, {
key: "closeModal",
value: function closeModal() {
this.setState({
isOpen: false
});
if (typeof this.props.onClose === 'function') {
this.props.onClose();
}
}
}, {
key: "keydownHandler",
value: function keydownHandler(e) {
if (e.keyCode === 27) {
this.closeModal();
}
}
}, {
key: "componentDidMount",
value: function componentDidMount() {
document.addEventListener('keydown', this.keydownHandler.bind(this));
window.addEventListener('resize', this.resizeModalVideoWhenHeightGreaterThanWindowHeight.bind(this));
this.setState({
modalVideoWidth: this.getWidthFulfillAspectRatio(this.props.ratio, window.innerHeight, window.innerWidth)
});
}
}, {
key: "componentWillUnmount",
value: function componentWillUnmount() {
document.removeEventListener('keydown', this.keydownHandler.bind(this));
window.removeEventListener('resize', this.resizeModalVideoWhenHeightGreaterThanWindowHeight.bind(this));
}
}, {
key: "componentDidUpdate",
value: function componentDidUpdate() {
if (this.state.isOpen && this.modal) {
this.modal.focus();
}
}
}, {
key: "updateFocus",
value: function updateFocus(e) {
if (this.state.isOpen) {
e.preventDefault();
e.stopPropagation();
if (e.keyCode === 9) {
if (this.modal === document.activeElement) {
this.modaliflame.focus();
} else if (this.modalbtn === document.activeElement) {
this.modal.focus();
}
}
}
}
/**
* Resize modal-video-iframe-wrap when window size changed when the height of the video is greater than the height of the window.
*/
}, {
key: "resizeModalVideoWhenHeightGreaterThanWindowHeight",
value: function resizeModalVideoWhenHeightGreaterThanWindowHeight() {
var _this2 = this;
clearTimeout(this.timeout);
this.timeout = setTimeout(function () {
var width = _this2.getWidthFulfillAspectRatio(_this2.props.ratio, window.innerHeight, window.innerWidth);
if (_this2.state.modalVideoWidth != width) {
_this2.setState({
modalVideoWidth: width
});
}
}, 10);
}
}, {
key: "getQueryString",
value: function getQueryString(obj) {
var url = '';
for (var key in obj) {
if (obj.hasOwnProperty(key)) {
if (obj[key] !== null) {
url += "".concat(key, "=").concat(obj[key], "&");
}
}
}
return url.substr(0, url.length - 1);
}
}, {
key: "getYoutubeUrl",
value: function getYoutubeUrl(youtube, videoId) {
var query = this.getQueryString(youtube);
return "//www.youtube.com/embed/".concat(videoId, "?").concat(query);
}
}, {
key: "getVimeoUrl",
value: function getVimeoUrl(vimeo, videoId) {
var query = this.getQueryString(vimeo);
return "//player.vimeo.com/video/".concat(videoId, "?").concat(query);
}
}, {
key: "getYoukuUrl",
value: function getYoukuUrl(youku, videoId) {
var query = this.getQueryString(youku);
return "//player.youku.com/embed/".concat(videoId, "?").concat(query);
}
}, {
key: "getVideoUrl",
value: function getVideoUrl(opt, videoId) {
if (opt.channel === 'youtube') {
return this.getYoutubeUrl(opt.youtube, videoId);
}
if (opt.channel === 'vimeo') {
return this.getVimeoUrl(opt.vimeo, videoId);
}
if (opt.channel === 'youku') {
return this.getYoukuUrl(opt.youku, videoId);
}
if (opt.channel === 'custom') {
return opt.url;
}
}
}, {
key: "getPadding",
value: function getPadding(ratio) {
var arr = ratio.split(':');
var width = Number(arr[0]);
var height = Number(arr[1]);
var padding = height * 100 / width;
return "".concat(padding, "%");
}
/**
* Calculate the width of the video fulfill aspect ratio.
* When the height of the video is greater than the height of the window,
* this function return the width that fulfill the aspect ratio for the height of the window.
* In other cases, this function return '100%'(the height relative to the width is determined by css).
*
* @param string ratio
* @param number maxWidth
* @returns number | '100%'
*/
}, {
key: "getWidthFulfillAspectRatio",
value: function getWidthFulfillAspectRatio(ratio, maxHeight, maxWidth) {
var arr = ratio.split(':');
var width = Number(arr[0]);
var height = Number(arr[1]);
// Height that fulfill the aspect ratio for maxWidth.
var videoHeight = maxWidth * (height / width);
if (maxHeight < videoHeight) {
// when the height of the video is greater than the height of the window.
// calculate the width that fulfill the aspect ratio for the height of the window.
// ex: 16:9 aspect ratio
// 16:9 = width : height
// → width = 16 / 9 * height
return Math.floor(width / height * maxHeight);
}
return '100%';
}
}, {
key: "render",
value: function render() {
var _this3 = this;
var modalVideoInnerStyle = {
width: this.state.modalVideoWidth
};
var modalVideoIframeWrapStyle = {
paddingBottom: this.getPadding(this.props.ratio)
};
return /*#__PURE__*/_react.default.createElement(_CSSTransition.default, {
classNames: this.props.classNames.modalVideoEffect,
timeout: this.props.animationSpeed
}, function () {
if (!_this3.state.isOpen) {
return null;
}
return /*#__PURE__*/_react.default.createElement("div", {
className: _this3.props.classNames.modalVideo,
tabIndex: "-1",
role: "dialog",
"area-modal": "true",
"aria-label": _this3.props.aria.openMessage,
onClick: _this3.closeModal,
ref: function ref(node) {
_this3.modal = node;
},
onKeyDown: _this3.updateFocus
}, /*#__PURE__*/_react.default.createElement("div", {
className: _this3.props.classNames.modalVideoBody
}, /*#__PURE__*/_react.default.createElement("div", {
className: _this3.props.classNames.modalVideoInner,
style: modalVideoInnerStyle
}, /*#__PURE__*/_react.default.createElement("div", {
className: _this3.props.classNames.modalVideoIframeWrap,
style: modalVideoIframeWrapStyle
}, _this3.props.children || /*#__PURE__*/_react.default.createElement("iframe", {
width: "460",
height: "230",
src: _this3.getVideoUrl(_this3.props, _this3.props.videoId),
frameBorder: "0",
allow: 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture',
allowFullScreen: _this3.props.allowFullScreen,
onKeyDown: _this3.updateFocus,
ref: function ref(node) {
_this3.modaliflame = node;
},
tabIndex: "-1"
}), /*#__PURE__*/_react.default.createElement("button", {
className: _this3.props.classNames.modalVideoCloseBtn,
"aria-label": _this3.props.aria.dismissBtnMessage,
ref: function ref(node) {
_this3.modalbtn = node;
},
onKeyDown: _this3.updateFocus
})))));
});
}
}], [{
key: "getDerivedStateFromProps",
value: function getDerivedStateFromProps(props) {
return {
isOpen: props.isOpen
};
}
}]);
return ModalVideo;
}(_react.default.Component);
exports.default = ModalVideo;
ModalVideo.defaultProps = {
channel: 'youtube',
isOpen: false,
youtube: {
autoplay: 1,
cc_load_policy: 1,
color: null,
controls: 1,
disablekb: 0,
enablejsapi: 0,
end: null,
fs: 1,
h1: null,
iv_load_policy: 1,
list: null,
listType: null,
loop: 0,
modestbranding: null,
origin: null,
playlist: null,
playsinline: null,
rel: 0,
showinfo: 1,
start: 0,
wmode: 'transparent',
theme: 'dark',
mute: 0
},
ratio: '16:9',
vimeo: {
api: false,
autopause: true,
autoplay: true,
byline: true,
callback: null,
color: null,
height: null,
loop: false,
maxheight: null,
maxwidth: null,
player_id: null,
portrait: true,
title: true,
width: null,
xhtml: false
},
youku: {
autoplay: 1,
show_related: 0
},
allowFullScreen: true,
animationSpeed: 300,
classNames: {
modalVideoEffect: 'modal-video-effect',
modalVideo: 'modal-video',
modalVideoClose: 'modal-video-close',
modalVideoBody: 'modal-video-body',
modalVideoInner: 'modal-video-inner',
modalVideoIframeWrap: 'modal-video-movie-wrap',
modalVideoCloseBtn: 'modal-video-close-btn'
},
aria: {
openMessage: 'You just opened the modal video',
dismissBtnMessage: 'Close the modal by clicking here'
}
};
================================================
FILE: nodemon.json
================================================
{
"execMap": {
"js": "node",
"jsx": "jsx {{filename}} | node"
},
"ext": "jsx scss",
"ignore": [
"test/dist",
"node_modules",
"lib"
],
"verbose": true
}
================================================
FILE: package.json
================================================
{
"name": "react-modal-video",
"version": "2.0.2",
"main": "lib/index.js",
"description": "Modal Video Viewer",
"author": "appleple",
"license": "MIT",
"scripts": {
"test": "eslint ./src/index.jsx --fix",
"build:js": "npm-run-all -p build:lib build:test",
"build:lib": "npm run babel",
"build:test": "browserify ./test/src/index.jsx -t babelify -o ./test/dist/index.js",
"build:sass": "npm-run-all -p sass sass:min",
"babel": "babel src --out-dir lib",
"sass": "sass ./scss/modal-video.scss ./css/modal-video.css --style expanded --no-source-map",
"sass:min": "sass ./scss/modal-video.scss ./css/modal-video.min.css --style compressed --no-source-map",
"watch:js": "onchange \"src/\" -- npm run build:js",
"watch:sass": "onchange \"scss\" -- npm run build:sass",
"watch:test": "onchange \"test/src\" -- npm run build:test",
"sync": "browser-sync start --server './' --files './test/dist/*.js' './css/*.css' --startPath '/test/index.html'",
"start": "npm-run-all -p watch:js watch:sass watch:test sync",
"deploy": "np --no-cleanup"
},
"repository": {
"type": "git",
"url": "https://github.com/appleple/react-modal-video.git"
},
"devDependencies": {
"@babel/cli": "^7.17.10",
"@babel/core": "^7.18.5",
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"babelify": "^10.0.0",
"browser-sync": "^2.27.10",
"browserify": "^17.0.0",
"eslint": "^8.17.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.26.0",
"npm-run-all": "^4.1.5",
"sass": "^1.52.3",
"onchange": "^7.1.0",
"np": "^7.6.1"
},
"dependencies": {
"core-js": "^3.27.2",
"react-transition-group": "^4.4.2"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.2.0",
"react-dom": "^17.0.0 || ^18.2.0"
}
}
================================================
FILE: readme.md
================================================
# react-modal-video
React Modal Video Component
## Features
- Not affected by dom structure.
- Beautiful transition
- Accessible for keyboard navigation and screen readers.
- Rich options for youtube API and Vimeo API
## Demo
[https://unpkg.com/react-modal-video@latest/test/index.html](https://unpkg.com/react-modal-video@latest/test/index.html)
## Install
### npm
```sh
npm install react-modal-video
```
## Usage
import sass file to your project
```scss
@import 'node_modules/react-modal-video/scss/modal-video.scss';
```
### Functional Implementation with Hooks
```jsx
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import ModalVideo from 'react-modal-video';
const App = () => {
const [isOpen, setOpen] = useState(false);
return (
<React.Fragment>
<ModalVideo
channel="youtube"
youtube={{ mute: 0, autoplay: 0 }}
isOpen={isOpen}
videoId="L61p2uyiMSo"
onClose={() => setOpen(false)}
/>
<button className="btn-primary" onClick={() => setOpen(true)}>
VIEW DEMO
</button>
</React.Fragment>
);
};
ReactDOM.render(<App />, document.getElementById('root'));
```
### Class Implementation
change "isOpen" property to open and close the modal-video
```jsx
import React from 'react';
import ReactDOM from 'react-dom';
import ModalVideo from 'react-modal-video';
class App extends React.Component {
constructor() {
super();
this.state = {
isOpen: false,
};
this.openModal = this.openModal.bind(this);
}
openModal() {
this.setState({ isOpen: true });
}
render() {
return (
<React.Fragment>
<ModalVideo
channel="youtube"
isOpen={this.state.isOpen}
videoId="L61p2uyiMSo"
onClose={() => this.setState({ isOpen: false })}
/>
<button onClick={this.openModal}>Open</button>
</React.Fragment>
);
}
}
ReactDOM.render(<App />, document.getElementById('root'));
```
## Options
- About YouTube options, please refer to https://developers.google.com/youtube/player_parameters?hl=en
- About Vimeo options, please refer to https://developer.vimeo.com/apis/oembed
<table style="min-width:100%;">
<tbody><tr>
<th colspan="2">properties</th>
<th>default</th>
</tr>
<tr>
<td colspan="2">channel</td>
<td>'youtube'</td>
</tr>
<tr>
<td rowspan="23">youtube</td>
<td>autoplay</td>
<td>1</td>
</tr>
<tr>
<td>cc_load_policy</td>
<td>1</td>
</tr>
<tr>
<td>color</td>
<td>null</td>
</tr>
<tr>
<td>controls</td>
<td>1</td>
</tr>
<tr>
<td>disablekb</td>
<td>0</td>
</tr>
<tr>
<td>enablejsapi</td>
<td>0</td>
</tr>
<tr>
<td>end</td>
<td>null</td>
</tr>
<tr>
<td>fs</td>
<td>1</td>
</tr>
<tr>
<td>h1</td>
<td>null</td>
</tr>
<tr>
<td>iv_load_policy</td>
<td>1</td>
</tr>
<tr>
<td>list</td>
<td>null</td>
</tr>
<tr>
<td>listType</td>
<td>null</td>
</tr>
<tr>
<td>loop</td>
<td>0</td>
</tr>
<tr>
<td>modestbranding</td>
<td>null</td>
</tr>
<tr>
<td>origin</td>
<td>null</td>
</tr>
<tr>
<td>playlist</td>
<td>null</td>
</tr>
<tr>
<td>playsinline</td>
<td>null</td>
</tr>
<tr>
<td>rel</td>
<td>0</td>
</tr>
<tr>
<td>showinfo</td>
<td>1</td>
</tr>
<tr>
<td>start</td>
<td>0</td>
</tr>
<tr>
<td>wmode</td>
<td>'transparent'</td>
</tr>
<tr>
<td>theme</td>
<td>'dark'</td>
</tr>
<tr>
<td>mute</td>
<td>0</td>
</tr>
<tr>
<td rowspan="15">vimeo</td>
<td>api</td>
<td>false</td>
</tr>
<tr>
<td>autopause</td>
<td>true</td>
</tr>
<tr>
<td>autoplay</td>
<td>true</td>
</tr>
<tr>
<td>byline</td>
<td>true</td>
</tr>
<tr>
<td>callback</td>
<td>null</td>
</tr>
<tr>
<td>color</td>
<td>null</td>
</tr>
<tr>
<td>height</td>
<td>null</td>
</tr>
<tr>
<td>loop</td>
<td>false</td>
</tr>
<tr>
<td>maxheight</td>
<td>null</td>
</tr>
<tr>
<td>maxwidth</td>
<td>null</td>
</tr>
<tr>
<td>player_id</td>
<td>null</td>
</tr>
<tr>
<td>portrait</td>
<td>true</td>
</tr>
<tr>
<td>title</td>
<td>true</td>
</tr>
<tr>
<td>width</td>
<td>null</td>
</tr>
<tr>
<td>xhtml</td>
<td>false</td>
</tr>
<tr>
<td rowspan="2">youku</td>
<td>autoplay</td>
<td>1</td>
</tr>
<tr>
<td>show_related</td>
<td>0</td>
</tr>
<tr>
<td rowspan="1">custom</td>
<td>url</td>
<td>MP4 URL / iframe URL</td>
</tr>
<tr>
<td colspan="2">ratio</td>
<td>'16:9'</td>
</tr>
<tr>
<td colspan="2">allowFullScreen</td>
<td>true</td>
</tr>
<tr>
<td colspan="2">animationSpeed</td>
<td>300</td>
</tr>
<tr>
<td rowspan="6">classNames</td>
<td>modalVideo</td>
<td>'modal-video'</td>
</tr>
<tr>
<td>modalVideoClose</td>
<td>'modal-video-close'</td>
</tr>
<tr>
<td>modalVideoBody</td>
<td>'modal-video-body'</td>
</tr>
<tr>
<td>modalVideoInner</td>
<td>'modal-video-inner'</td>
</tr>
<tr>
<td>modalVideoIframeWrap</td>
<td>'modal-video-movie-wrap'</td>
</tr>
<tr>
<td>modalVideoCloseBtn</td>
<td>'modal-video-close-btn'</td>
</tr>
<tr>
<td rowspan="2">aria</td>
<td>openMessage</td>
<td>'You just opened the modal video'</td>
</tr>
<tr>
<td>dismissBtnMessage</td>
<td>'Close the modal by clicking here'</td>
</tr>
</tbody></table>
## FAQ
### How to track YouTube videos playing in modal-video by GA4?</h3>
1. Enable JS API. Turn `enablejsapi` property to `1`.
2. Load YouTube Iframe API. Add `<script src="https://www.youtube.com/iframe_api"></script>` in HTML file.
## Licence
[MIT](https://github.com/appleple/modal-video.js/blob/master/LICENSE)
================================================
FILE: scss/modal-video.scss
================================================
$animation-speed: .3s;
$animation-function: ease-out;
$backdrop-color: rgba(0, 0, 0, .5);
@keyframes modal-video {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-video-inner {
from {
transform: translate(0, 100px);
}
to {
transform: translate(0, 0);
}
}
.modal-video {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: $backdrop-color;
z-index: 1000000;
cursor: pointer;
opacity: 1;
animation-timing-function: $animation-function;
animation-duration: $animation-speed;
animation-name: modal-video;
-webkit-transition: opacity $animation-speed $animation-function;
-moz-transition: opacity $animation-speed $animation-function;
-ms-transition: opacity $animation-speed $animation-function;
-o-transition: opacity $animation-speed $animation-function;
transition: opacity $animation-speed $animation-function;
}
.modal-video-effect-exit {
opacity: 0;
& .modal-video-movie-wrap {
-webkit-transform: translate(0, 100px);
-moz-transform: translate(0, 100px);
-ms-transform: translate(0, 100px);
-o-transform: translate(0, 100px);
transform: translate(0, 100px);
}
}
.modal-video-body {
max-width: 960px;
width: 100%;
height: 100%;
margin: 0 auto;
padding: 0 10px;
display: flex;
justify-content: center;
box-sizing: border-box;
}
.modal-video-inner {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
@media (orientation: landscape) {
padding: 10px 60px;
box-sizing: border-box;
}
}
.modal-video-movie-wrap {
width: 100%;
height: 0;
position: relative;
padding-bottom: 56.25%;
background-color: #333;
animation-timing-function: $animation-function;
animation-duration: $animation-speed;
animation-name: modal-video-inner;
-webkit-transform: translate(0, 0);
-moz-transform: translate(0, 0);
-ms-transform: translate(0, 0);
-o-transform: translate(0, 0);
transform: translate(0, 0);
-webkit-transition: -webkit-transform $animation-speed $animation-function;
-moz-transition: -moz-transform $animation-speed $animation-function;
-ms-transition: -ms-transform $animation-speed $animation-function;
-o-transition: -o-transform $animation-speed $animation-function;
transition: transform $animation-speed $animation-function;
& iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}
.modal-video-close-btn {
position: absolute;
z-index: 2;
top: -45px;
right: 0px;
display: inline-block;
width: 35px;
height: 35px;
overflow: hidden;
border: none;
background: transparent;
@media (orientation: landscape) {
top: 0;
right: -45px;
}
&:before {
transform: rotate(45deg);
}
&:after {
transform: rotate(-45deg);
}
&:before,
&:after {
content: '';
position: absolute;
height: 2px;
width: 100%;
top: 50%;
left: 0;
margin-top: -1px;
background: #fff;
border-radius: 5px;
margin-top: -6px;
}
}
================================================
FILE: src/index.jsx
================================================
import React from 'react';
import CSSTransition from 'react-transition-group/CSSTransition';
export default class ModalVideo extends React.Component {
constructor(props) {
super(props);
this.state = {
isOpen: false,
modalVideoWidth: '100%'
};
this.closeModal = this.closeModal.bind(this);
this.updateFocus = this.updateFocus.bind(this);
this.timeout; // used for resizing video.
}
openModal() {
this.setState({ isOpen: true });
}
closeModal() {
this.setState({ isOpen: false });
if (typeof this.props.onClose === 'function') {
this.props.onClose();
}
}
keydownHandler(e) {
if (e.keyCode === 27) {
this.closeModal();
}
}
componentDidMount() {
document.addEventListener('keydown', this.keydownHandler.bind(this));
window.addEventListener('resize', this.resizeModalVideoWhenHeightGreaterThanWindowHeight.bind(this));
this.setState({
modalVideoWidth: this.getWidthFulfillAspectRatio(this.props.ratio, window.innerHeight, window.innerWidth)
});
}
componentWillUnmount() {
document.removeEventListener('keydown', this.keydownHandler.bind(this));
window.removeEventListener('resize', this.resizeModalVideoWhenHeightGreaterThanWindowHeight.bind(this));
}
static getDerivedStateFromProps(props) {
return { isOpen: props.isOpen };
}
componentDidUpdate() {
if (this.state.isOpen && this.modal) {
this.modal.focus();
}
}
updateFocus(e) {
if (this.state.isOpen) {
e.preventDefault();
e.stopPropagation();
if (e.keyCode === 9) {
if (this.modal === document.activeElement) {
this.modaliflame.focus();
}
else if (this.modalbtn === document.activeElement) {
this.modal.focus();
}
}
}
}
/**
* Resize modal-video-iframe-wrap when window size changed when the height of the video is greater than the height of the window.
*/
resizeModalVideoWhenHeightGreaterThanWindowHeight() {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
const width = this.getWidthFulfillAspectRatio(this.props.ratio, window.innerHeight, window.innerWidth);
if (this.state.modalVideoWidth != width) {
this.setState({
modalVideoWidth: width
});
}
}, 10);
}
getQueryString(obj) {
let url = '';
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
if (obj[key] !== null) {
url += `${key}=${obj[key]}&`;
}
}
}
return url.substr(0, url.length - 1);
}
getYoutubeUrl(youtube, videoId) {
const query = this.getQueryString(youtube);
return `//www.youtube.com/embed/${videoId}?${query}`;
}
getVimeoUrl(vimeo, videoId) {
const query = this.getQueryString(vimeo);
return `//player.vimeo.com/video/${videoId}?${query}`;
}
getYoukuUrl(youku, videoId) {
const query = this.getQueryString(youku);
return `//player.youku.com/embed/${videoId}?${query}`;
}
getVideoUrl(opt, videoId) {
if (opt.channel === 'youtube') {
return this.getYoutubeUrl(opt.youtube, videoId);
} if (opt.channel === 'vimeo') {
return this.getVimeoUrl(opt.vimeo, videoId);
} if (opt.channel === 'youku') {
return this.getYoukuUrl(opt.youku, videoId);
} if (opt.channel === 'custom') {
return opt.url;
}
}
getPadding(ratio) {
const arr = ratio.split(':');
const width = Number(arr[0]);
const height = Number(arr[1]);
const padding = height * 100 / width;
return `${padding}%`;
}
/**
* Calculate the width of the video fulfill aspect ratio.
* When the height of the video is greater than the height of the window,
* this function return the width that fulfill the aspect ratio for the height of the window.
* In other cases, this function return '100%'(the height relative to the width is determined by css).
*
* @param string ratio
* @param number maxWidth
* @returns number | '100%'
*/
getWidthFulfillAspectRatio(ratio, maxHeight, maxWidth) {
const arr = ratio.split(':');
const width = Number(arr[0]);
const height = Number(arr[1]);
// Height that fulfill the aspect ratio for maxWidth.
const videoHeight = maxWidth * (height / width);
if (maxHeight < videoHeight) {
// when the height of the video is greater than the height of the window.
// calculate the width that fulfill the aspect ratio for the height of the window.
// ex: 16:9 aspect ratio
// 16:9 = width : height
// → width = 16 / 9 * height
return Math.floor(width / height * maxHeight);
}
return '100%';
}
render() {
const modalVideoInnerStyle = {
width: this.state.modalVideoWidth
};
const modalVideoIframeWrapStyle = {
paddingBottom: this.getPadding(this.props.ratio)
};
return (
<CSSTransition
classNames={this.props.classNames.modalVideoEffect}
timeout={this.props.animationSpeed}
>
{() => {
if (!this.state.isOpen) {
return null;
}
return (
<div className={this.props.classNames.modalVideo} tabIndex='-1' role='dialog' area-modal="true"
aria-label={this.props.aria.openMessage} onClick={this.closeModal} ref={(node) => {this.modal = node; }} onKeyDown={this.updateFocus}>
<div className={this.props.classNames.modalVideoBody}>
<div className={this.props.classNames.modalVideoInner} style={modalVideoInnerStyle}>
<div className={this.props.classNames.modalVideoIframeWrap} style={modalVideoIframeWrapStyle}>
{
this.props.children
|| <iframe
width='460'
height='230'
src={this.getVideoUrl(this.props, this.props.videoId)}
frameBorder='0'
allow={'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture'}
allowFullScreen={this.props.allowFullScreen}
onKeyDown={this.updateFocus}
ref={(node) => {this.modaliflame = node; }}
tabIndex='-1' />
}
<button
className={this.props.classNames.modalVideoCloseBtn}
aria-label={this.props.aria.dismissBtnMessage}
ref={(node) => { this.modalbtn = node; }}
onKeyDown={this.updateFocus} />
</div>
</div>
</div>
</div>);
}}
</CSSTransition>
);
}
}
ModalVideo.defaultProps = {
channel: 'youtube',
isOpen: false,
youtube: {
autoplay: 1,
cc_load_policy: 1,
color: null,
controls: 1,
disablekb: 0,
enablejsapi: 0,
end: null,
fs: 1,
h1: null,
iv_load_policy: 1,
list: null,
listType: null,
loop: 0,
modestbranding: null,
origin: null,
playlist: null,
playsinline: null,
rel: 0,
showinfo: 1,
start: 0,
wmode: 'transparent',
theme: 'dark',
mute: 0
},
ratio: '16:9',
vimeo: {
api: false,
autopause: true,
autoplay: true,
byline: true,
callback: null,
color: null,
height: null,
loop: false,
maxheight: null,
maxwidth: null,
player_id: null,
portrait: true,
title: true,
width: null,
xhtml: false
},
youku: {
autoplay: 1,
show_related: 0
},
allowFullScreen: true,
animationSpeed: 300,
classNames: {
modalVideoEffect: 'modal-video-effect',
modalVideo: 'modal-video',
modalVideoClose: 'modal-video-close',
modalVideoBody: 'modal-video-body',
modalVideoInner: 'modal-video-inner',
modalVideoIframeWrap: 'modal-video-movie-wrap',
modalVideoCloseBtn: 'modal-video-close-btn'
},
aria: {
openMessage: 'You just opened the modal video',
dismissBtnMessage: 'Close the modal by clicking here'
}
};
================================================
FILE: test/index.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="../css/modal-video.min.css">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>modal-video.js</title>
</head>
<body>
<div id="root"></div>
<script src="./dist/index.js"></script>
</body>
</html>
================================================
FILE: test/src/index.jsx
================================================
import React from 'react';
import ReactDOM from 'react-dom';
import ModalVideo from '../../lib/index';
class App extends React.Component {
constructor() {
super();
this.state = {
isOpen: false,
isOpenYouku: false,
isOpenCustom: false,
customUrl: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
};
this.openModal = this.openModal.bind(this);
}
openModal() {
this.setState({ isOpen: true });
}
render() {
return (
<React.Fragment>
<ModalVideo
channel="youtube"
isOpen={this.state.isOpen}
videoId="L61p2uyiMSo"
youtube={{ mute: 0, autoplay: 0 }}
onClose={() => this.setState({ isOpen: false })}
/>
<button onClick={this.openModal}>Open YouTube</button>
<ModalVideo
channel="vimeo"
isOpen={this.state.isOpenVimeo}
videoId="336257407"
onClose={() => this.setState({ isOpenVimeo: false })}
/>
<button onClick={() => this.setState({ isOpenVimeo: true })}>Open Vimeo</button>
<ModalVideo
channel="youku"
isOpen={this.state.isOpenYouku}
videoId="XMTczNjgzMDYwNA="
onClose={() => this.setState({ isOpenYouku: false })}
/>
<button onClick={() => this.setState({ isOpenYouku: true })}>Open youku</button>
<ModalVideo
channel="custom"
isOpen={this.state.isOpenCustom}
url={this.state.customUrl}
onClose={() => this.setState({ isOpenCustom: false })}
/>
<button onClick={() => this.setState({ isOpenCustom: true })}>Open Custom</button>
</React.Fragment>
);
}
}
ReactDOM.render(<App />, document.getElementById('root'));
gitextract_so3v8z3u/
├── .editorconfig
├── .eslintrc
├── .gitignore
├── .node-version
├── LICENSE
├── babel.config.js
├── circle.yml
├── css/
│ └── modal-video.css
├── lib/
│ └── index.js
├── nodemon.json
├── package.json
├── readme.md
├── scss/
│ └── modal-video.scss
├── src/
│ └── index.jsx
└── test/
├── index.html
└── src/
└── index.jsx
SYMBOL INDEX (38 symbols across 3 files)
FILE: lib/index.js
function _typeof (line 3) | function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "fun...
function _interopRequireDefault (line 23) | function _interopRequireDefault(obj) { return obj && obj.__esModule ? ob...
function _classCallCheck (line 24) | function _classCallCheck(instance, Constructor) { if (!(instance instanc...
function _defineProperties (line 25) | function _defineProperties(target, props) { for (var i = 0; i < props.le...
function _createClass (line 26) | function _createClass(Constructor, protoProps, staticProps) { if (protoP...
function _toPropertyKey (line 27) | function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); re...
function _toPrimitive (line 28) | function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || ...
function _inherits (line 29) | function _inherits(subClass, superClass) { if (typeof superClass !== "fu...
function _setPrototypeOf (line 30) | function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf...
function _createSuper (line 31) | function _createSuper(Derived) { var hasNativeReflectConstruct = _isNati...
function _possibleConstructorReturn (line 32) | function _possibleConstructorReturn(self, call) { if (call && (_typeof(c...
function _assertThisInitialized (line 33) | function _assertThisInitialized(self) { if (self === void 0) { throw new...
function _isNativeReflectConstruct (line 34) | function _isNativeReflectConstruct() { if (typeof Reflect === "undefined...
function _getPrototypeOf (line 35) | function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? ...
function ModalVideo (line 39) | function ModalVideo(props) {
FILE: src/index.jsx
class ModalVideo (line 4) | class ModalVideo extends React.Component {
method constructor (line 5) | constructor(props) {
method openModal (line 17) | openModal() {
method closeModal (line 21) | closeModal() {
method keydownHandler (line 28) | keydownHandler(e) {
method componentDidMount (line 34) | componentDidMount() {
method componentWillUnmount (line 42) | componentWillUnmount() {
method getDerivedStateFromProps (line 47) | static getDerivedStateFromProps(props) {
method componentDidUpdate (line 51) | componentDidUpdate() {
method updateFocus (line 57) | updateFocus(e) {
method resizeModalVideoWhenHeightGreaterThanWindowHeight (line 76) | resizeModalVideoWhenHeightGreaterThanWindowHeight() {
method getQueryString (line 89) | getQueryString(obj) {
method getYoutubeUrl (line 101) | getYoutubeUrl(youtube, videoId) {
method getVimeoUrl (line 106) | getVimeoUrl(vimeo, videoId) {
method getYoukuUrl (line 111) | getYoukuUrl(youku, videoId) {
method getVideoUrl (line 116) | getVideoUrl(opt, videoId) {
method getPadding (line 128) | getPadding(ratio) {
method getWidthFulfillAspectRatio (line 146) | getWidthFulfillAspectRatio(ratio, maxHeight, maxWidth) {
method render (line 167) | render() {
FILE: test/src/index.jsx
class App (line 5) | class App extends React.Component {
method constructor (line 6) | constructor() {
method openModal (line 17) | openModal() {
method render (line 21) | render() {
Condensed preview — 16 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (44K chars).
[
{
"path": ".editorconfig",
"chars": 406,
"preview": "# editorconfig.org\nroot = true\n\n[*.js]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_traili"
},
{
"path": ".eslintrc",
"chars": 263,
"preview": "{\n \"env\": {\n \"browser\": true\n },\n \"globals\": {\n \"document\": true,\n \"window\": true\n },\n \"rules\":{\n \"comm"
},
{
"path": ".gitignore",
"chars": 50,
"preview": "node_modules/*\nyarn.lock\n.DS_Store\n.idea\ntest/dist"
},
{
"path": ".node-version",
"chars": 8,
"preview": "18.12.1\n"
},
{
"path": "LICENSE",
"chars": 1065,
"preview": "MIT License\n\nCopyright (c) 2016 appleple\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\no"
},
{
"path": "babel.config.js",
"chars": 215,
"preview": "module.exports = {\n presets: [\n ['@babel/preset-env',\n {\n targets: {\n ie: 11,\n },\n "
},
{
"path": "circle.yml",
"chars": 120,
"preview": "machine:\n node:\n version: 6.2.0\ndependencies:\n override:\n - \"npm install\"\ntest:\n override:\n - \"npm run test\""
},
{
"path": "css/modal-video.css",
"chars": 2814,
"preview": "@keyframes modal-video {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n@keyframes modal-video-inner {\n from"
},
{
"path": "lib/index.js",
"chars": 14560,
"preview": "\"use strict\";\n\nfunction _typeof(obj) { \"@babel/helpers - typeof\"; return _typeof = \"function\" == typeof Symbol && \"symbo"
},
{
"path": "nodemon.json",
"chars": 215,
"preview": "{\n \"execMap\": {\n \"js\": \"node\",\n \"jsx\": \"jsx {{filename}} | node\"\n },\n \"ext\": \"jsx scss\",\n \"ign"
},
{
"path": "package.json",
"chars": 1898,
"preview": "{\n \"name\": \"react-modal-video\",\n \"version\": \"2.0.2\",\n \"main\": \"lib/index.js\",\n \"description\": \"Modal Video Viewer\",\n"
},
{
"path": "readme.md",
"chars": 5673,
"preview": "# react-modal-video\n\nReact Modal Video Component\n\n## Features\n\n- Not affected by dom structure.\n- Beautiful transition\n-"
},
{
"path": "scss/modal-video.scss",
"chars": 2982,
"preview": "$animation-speed: .3s;\n$animation-function: ease-out;\n$backdrop-color: rgba(0, 0, 0, .5);\n\n@keyframes modal-video {\n\tfro"
},
{
"path": "src/index.jsx",
"chars": 8136,
"preview": "import React from 'react';\nimport CSSTransition from 'react-transition-group/CSSTransition';\n\nexport default class Modal"
},
{
"path": "test/index.html",
"chars": 382,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, in"
},
{
"path": "test/src/index.jsx",
"chars": 1793,
"preview": "import React from 'react';\nimport ReactDOM from 'react-dom';\nimport ModalVideo from '../../lib/index';\n\nclass App extend"
}
]
About this extraction
This page contains the full source code of the appleple/react-modal-video GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 16 files (39.6 KB), approximately 11.5k tokens, and a symbol index with 38 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.