2012-06-06 06:13:19 +02:00
/ * *
2013-04-13 21:00:13 +02:00
* EGroupware eTemplate2 - JS widget for HTML editing
2012-06-06 06:13:19 +02:00
*
* @ license http : //opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @ package etemplate
* @ subpackage api
* @ link http : //www.egroupware.org
2018-10-19 16:35:18 +02:00
* @ author Hadi Nategh < hn @ egroupware . org >
* @ copyright Hadi Nategh < hn @ egroupware . org >
2012-06-06 06:13:19 +02:00
* @ version $Id$
* /
/ * e g w : u s e s
jsapi . jsapi ; // Needed for egw_seperateJavaScript
2018-10-19 16:35:18 +02:00
/ a p i / j s / t i n y m c e / t i n y m c e . m i n . j s ;
2018-12-12 23:23:13 +01:00
et2 _core _editableWidget ;
2012-06-06 06:13:19 +02:00
* /
2013-04-13 21:00:13 +02:00
/ * *
* @ augments et2 _inputWidget
* /
2018-12-12 23:23:13 +01:00
var et2 _htmlarea = ( function ( ) { "use strict" ; return et2 _editableWidget . extend ( [ et2 _IResizeable ] ,
2013-04-13 21:00:13 +02:00
{
2012-06-06 06:13:19 +02:00
attributes : {
2019-02-27 11:00:53 +01:00
mode : {
2012-06-06 06:13:19 +02:00
'name' : 'Mode' ,
'description' : 'One of {ascii|simple|extended|advanced}' ,
2018-10-22 12:37:45 +02:00
'default' : '' ,
2012-06-06 06:13:19 +02:00
'type' : 'string'
} ,
2019-02-27 11:00:53 +01:00
height : {
2012-06-06 06:13:19 +02:00
'name' : 'Height' ,
'default' : et2 _no _init ,
'type' : 'string'
} ,
2019-02-27 11:00:53 +01:00
width : {
2012-06-06 06:13:19 +02:00
'name' : 'Width' ,
'default' : et2 _no _init ,
'type' : 'string'
} ,
2014-02-14 11:14:28 +01:00
value : {
name : "Value" ,
description : "The value of the widget" ,
type : "html" , // "string" would remove html tags by running html_entity_decode
default : et2 _no _init
2014-11-27 14:44:50 +01:00
} ,
2015-08-07 16:18:07 +02:00
imageUpload : {
name : "imageUpload" ,
2017-01-31 10:05:13 +01:00
description : "Url to upload images dragged in or id of link_to widget to it's vfs upload. Can also be just a name for which content array contains a path to upload the picture." ,
2015-08-07 16:18:07 +02:00
type : "string" ,
default : null
2018-10-19 16:35:18 +02:00
} ,
file _picker _callback : {
name : "File picker callback" ,
description : "Callback function to get called when file picker is clicked" ,
type : 'js' ,
default : et2 _no _init
} ,
images _upload _handler : {
name : "Images upload handler" ,
description : "Callback function for handling image upload" ,
type : 'js' ,
default : et2 _no _init
2018-10-22 12:37:45 +02:00
} ,
menubar : {
name : "Menubar" ,
description : "Display menubar at the top of the editor" ,
type : "boolean" ,
default : true
2018-11-05 15:35:13 +01:00
} ,
statusbar : {
name : "Status bar" ,
2019-03-05 14:08:19 +01:00
description : "Enable/disable status bar on the bottom of editor" ,
2018-11-05 15:35:13 +01:00
type : "boolean" ,
default : true
2019-03-05 14:08:19 +01:00
} ,
valid _children : {
name : "Valid children" ,
description : "Enables to control what child tag is allowed or not allowed of the present tag. For instance: +body[style], makes style tag allowed inside body" ,
type : "string" ,
default : ""
2014-02-14 11:14:28 +01:00
}
2012-06-06 06:13:19 +02:00
} ,
2013-04-13 21:00:13 +02:00
/ * *
* Constructor
2013-11-04 11:13:28 +01:00
*
2013-04-13 21:00:13 +02:00
* @ param _parent
* @ param _attrs
* @ memberOf et2 _htmlarea
* /
2012-06-06 06:13:19 +02:00
init : function ( _parent , _attrs ) {
this . _super . apply ( this , arguments ) ;
2018-10-19 16:35:18 +02:00
this . editor = null ; // TinyMce editor instance
this . supportedWidgetClasses = [ ] ; // Allow no child widgets
2018-12-12 23:23:13 +01:00
this . htmlNode = jQuery ( document . createElement ( this . options . readonly ? "div" : "textarea" ) )
2013-06-26 21:34:14 +02:00
. css ( 'height' , this . options . height )
. addClass ( 'et2_textbox_ro' ) ;
2012-06-06 06:13:19 +02:00
this . setDOMNode ( this . htmlNode [ 0 ] ) ;
} ,
2013-11-04 11:13:28 +01:00
2018-10-19 16:35:18 +02:00
/ * *
*
* @ returns { undefined }
* /
2012-06-06 06:13:19 +02:00
doLoadingFinished : function ( ) {
this . _super . apply ( this , arguments ) ;
2018-12-12 23:23:13 +01:00
this . init _editor ( ) ;
} ,
init _editor : function ( ) {
if ( this . mode == 'ascii' || this . editor != null || this . options . readonly ) return ;
2018-10-23 15:50:55 +02:00
var imageUpload = '' ;
2018-10-30 12:02:55 +01:00
var self = this ;
2018-10-23 17:10:33 +02:00
if ( this . options . imageUpload && this . options . imageUpload [ 0 ] !== '/' && this . options . imageUpload . substr ( 0 , 4 ) != 'http' )
2018-10-23 15:50:55 +02:00
{
imageUpload = egw . ajaxUrl ( "EGroupware\\Api\\Etemplate\\Widget\\Vfs::ajax_htmlarea_upload" ) +
'&request_id=' + this . getInstanceManager ( ) . etemplate _exec _id + '&widget_id=' + this . options . imageUpload + '&type=htmlarea' ;
imageUpload = imageUpload . substr ( egw . webserverUrl . length + 1 ) ;
}
2018-10-23 17:10:33 +02:00
else if ( imageUpload )
2018-10-23 15:50:55 +02:00
{
imageUpload = this . options . imageUpload . substr ( egw . webserverUrl . length + 1 ) ;
}
2018-10-23 17:10:33 +02:00
else
{
imageUpload = egw . ajaxUrl ( "EGroupware\\Api\\Etemplate\\Widget\\Vfs::ajax_htmlarea_upload" ) +
'&request_id=' + this . getInstanceManager ( ) . etemplate _exec _id + '&type=htmlarea' ;
}
2018-10-19 16:35:18 +02:00
// default settings for initialization
var settings = {
target : this . htmlNode [ 0 ] ,
body _id : this . dom + '_htmlarea' ,
menubar : false ,
2018-11-05 15:35:13 +01:00
statusbar : this . options . statusbar ,
2018-10-19 16:35:18 +02:00
branding : false ,
resize : false ,
height : this . options . height ,
width : this . options . width ,
min _height : 100 ,
2018-12-20 12:38:13 +01:00
convert _urls : false ,
2018-10-25 18:18:31 +02:00
language : et2 _htmlarea . LANGUAGE _CODE [ egw . preference ( 'lang' , 'common' ) ] ,
2018-10-19 16:35:18 +02:00
paste _data _images : true ,
browser _spellcheck : true ,
2018-10-22 12:37:45 +02:00
contextmenu : false ,
2018-10-23 15:50:55 +02:00
images _upload _url : imageUpload ,
2018-10-19 16:35:18 +02:00
file _picker _callback : jQuery . proxy ( this . _file _picker _callback , this ) ,
2018-10-23 15:50:55 +02:00
images _upload _handler : this . options . images _upload _handler ,
2018-10-19 16:35:18 +02:00
init _instance _callback : jQuery . proxy ( this . _instanceIsReady , this ) ,
2018-11-22 15:42:14 +01:00
auto _focus : false ,
2019-03-05 14:08:19 +01:00
valid _children : this . options . valid _children ,
2018-10-19 16:35:18 +02:00
plugins : [
2018-11-22 15:42:14 +01:00
"print searchreplace autolink directionality " ,
"visualblocks visualchars image link media template " ,
"codesample table charmap hr pagebreak nonbreaking anchor toc " ,
"insertdatetime advlist lists textcolor wordcount imagetools " ,
"colorpicker textpattern help paste code searchreplace tabfocus"
2018-10-19 16:35:18 +02:00
] ,
2018-11-28 18:06:20 +01:00
toolbar : et2 _htmlarea . TOOLBAR _SIMPLE ,
2018-10-19 16:35:18 +02:00
block _formats : "Paragraph=p;Heading 1=h1;Heading 2=h2;Heading 3=h3;" +
"Heading 4=h4;Heading 5=h5;Heading 6=h6;Preformatted=pre" ,
font _formats : "Andale Mono=andale mono,times;Arial=arial,helvetica," +
"sans-serif;Arial Black=arial black,avant garde;Book Antiqua=book " +
"antiqua,palatino;Comic Sans MS=comic sans ms,sans-serif;" +
"Courier New=courier new,courier;Georgia=georgia,palatino;" +
"Helvetica=helvetica;Impact=impact,chicago;Symbol=symbol;" +
"Tahoma=tahoma,arial,helvetica,sans-serif;Terminal=terminal," +
"monaco;Times New Roman=times new roman,times;Trebuchet " +
"MS=trebuchet ms,geneva;Verdana=verdana,geneva;Webdings=webdings;" +
"Wingdings=wingdings,zapf dingbats" ,
fontsize _formats : '8pt 10pt 12pt 14pt 18pt 24pt 36pt' ,
2019-01-14 10:37:07 +01:00
setup : function ( ed )
{
ed . on ( 'init' , function ( )
{
this . getDoc ( ) . body . style . fontSize = egw . preference ( 'rte_font_size' , 'common' )
+ egw . preference ( 'rte_font_unit' , 'common' ) ;
this . getDoc ( ) . body . style . fontFamily = egw . preference ( 'rte_font' , 'common' ) ;
} ) ;
}
2018-10-19 16:35:18 +02:00
} ;
2014-11-27 14:44:50 +01:00
2018-10-19 16:35:18 +02:00
// extend default settings with configured options and preferences
jQuery . extend ( settings , this . _extendedSettings ( ) ) ;
this . tinymce = tinymce . init ( settings ) ;
2018-10-30 12:02:55 +01:00
// make sure value gets set in case of widget gets loaded by delay like
// inside an inactive tabs
this . tinymce . then ( ( ) => {
self . set _value ( self . htmlNode . val ( ) ) ;
if ( self . editor && self . editor . editorContainer )
{
jQuery ( self . editor . editorContainer ) . height ( self . options . height ) ;
}
} ) ;
2018-10-19 16:35:18 +02:00
} ,
2014-06-30 23:28:03 +02:00
2018-10-29 15:11:32 +01:00
/ * *
* set disabled
*
* @ param { type } _value
* @ returns { undefined }
* /
set _disabled : function ( _value )
{
this . _super . apply ( this , arguments ) ;
if ( _value )
{
jQuery ( this . tinymce _container ) . css ( 'display' , 'none' ) ;
}
else
{
jQuery ( this . tinymce _container ) . css ( 'display' , 'flex' ) ;
}
} ,
2018-12-12 23:23:13 +01:00
set _readonly : function ( _value )
{
if ( this . options . readonly === _value ) return ;
var value = this . get _value ( ) ;
this . options . readonly = _value ;
if ( this . options . readonly )
{
2019-02-27 11:00:53 +01:00
if ( this . editor ) this . editor . remove ( ) ;
2018-12-12 23:23:13 +01:00
this . htmlNode = jQuery ( document . createElement ( this . options . readonly ? "div" : "textarea" ) )
. css ( 'height' , this . options . height )
. addClass ( 'et2_textbox_ro' ) ;
this . editor = null ;
this . setDOMNode ( this . htmlNode [ 0 ] ) ;
this . set _value ( value ) ;
}
else
{
if ( ! this . editor )
{
this . htmlNode = jQuery ( document . createElement ( "textarea" ) )
2019-02-27 11:00:53 +01:00
. css ( 'height' , ( this . options . editable _height ? this . options . editable _height : this . options . height ) )
2018-12-12 23:23:13 +01:00
. val ( value ) ;
this . setDOMNode ( this . htmlNode [ 0 ] ) ;
this . init _editor ( ) ;
}
}
} ,
2018-10-19 16:35:18 +02:00
/ * *
2018-10-23 13:04:20 +02:00
* Callback function runs when the filepicker in image dialog is clicked
2018-10-19 16:35:18 +02:00
*
* @ param { type } _callback
* @ param { type } _value
* @ param { type } _meta
* @ returns { unresolved }
* /
_file _picker _callback : function ( _callback , _value , _meta ) {
if ( typeof this . file _picker _callback == 'function' ) return this . file _picker _callback . call ( arguments , this ) ;
2018-10-23 13:04:20 +02:00
var callback = _callback ;
2014-07-02 22:50:39 +02:00
2018-10-23 13:04:20 +02:00
// Don't rely only on app_name to fetch et2 object as app_name may not
// always represent current app of the window, e.g.: mail admin account.
// Try to fetch et2 from its template name.
var etemplate = jQuery ( 'form' ) . data ( 'etemplate' ) ;
var et2 = { } ;
if ( etemplate && etemplate . name && ! app [ egw ( window ) . app _name ( ) ] )
{
et2 = etemplate2 . getByTemplate ( etemplate . name ) [ 0 ] [ 'widgetContainer' ] ;
}
else
{
et2 = app [ egw ( window ) . app _name ( ) ] . et2 ;
}
var vfsSelect = et2 _createWidget ( 'vfs-select' , {
id : 'upload' ,
mode : 'open' ,
name : '' ,
button _caption : "Link" ,
button _label : "Link" ,
dialog _title : "Link file" ,
method : "download"
} , et2 ) ;
jQuery ( vfsSelect . getDOMNode ( ) ) . on ( 'change' , function ( ) {
callback ( vfsSelect . get _value ( ) , { alt : vfsSelect . get _value ( ) } ) ;
} ) ;
// start the file selector dialog
vfsSelect . click ( ) ;
2018-10-19 16:35:18 +02:00
} ,
2014-07-02 22:50:39 +02:00
2018-10-19 16:35:18 +02:00
/ * *
* Callback when instance is ready
*
* @ param { type } _editor
* /
_instanceIsReady : function ( _editor ) {
console . log ( "Editor: " + _editor . id + " is now initialized." ) ;
2018-11-22 15:42:14 +01:00
// try to reserve focus state as running command on editor may steal the
// current focus.
2018-11-22 16:48:32 +01:00
var focusedEl = jQuery ( ':focus' ) ;
2018-10-19 16:35:18 +02:00
this . editor = _editor ;
2018-10-29 15:11:32 +01:00
if ( ! this . disabled ) jQuery ( this . editor . editorContainer ) . css ( 'display' , 'flex' ) ;
this . tinymce _container = this . editor . editorContainer ;
2018-11-22 15:42:14 +01:00
// go back to reserved focused element
focusedEl . focus ( ) ;
2018-10-19 16:35:18 +02:00
} ,
2016-02-29 21:40:43 +01:00
2018-10-19 16:35:18 +02:00
/ * *
2018-10-24 14:48:19 +02:00
* Takes all relevant preferences into account and set settings accordingly
2018-10-19 16:35:18 +02:00
*
2018-10-24 14:48:19 +02:00
* @ returns { object } returns a object including all settings
2018-10-19 16:35:18 +02:00
* /
_extendedSettings : function ( ) {
2016-02-29 21:40:43 +01:00
2018-10-22 12:37:45 +02:00
var rte _menubar = egw . preference ( 'rte_menubar' , 'common' ) ;
2018-10-24 14:48:19 +02:00
var rte _toolbar = egw . preference ( 'rte_toolbar' , 'common' ) ;
2018-10-19 16:35:18 +02:00
var settings = {
2018-10-24 14:48:19 +02:00
fontsize _formats : et2 _htmlarea . FONT _SIZE _FORMATS [ egw . preference ( 'rte_font_unit' , 'common' ) ] ,
2018-10-22 12:37:45 +02:00
menubar : parseInt ( rte _menubar ) && this . menubar ? true : typeof rte _menubar != 'undefined' ? false : this . menubar
2018-10-19 16:35:18 +02:00
} ;
2014-07-02 22:50:39 +02:00
2018-11-28 18:06:20 +01:00
switch ( this . mode )
2018-10-19 16:35:18 +02:00
{
case 'simple' :
2018-10-24 14:48:19 +02:00
settings . toolbar = et2 _htmlarea . TOOLBAR _SIMPLE ;
2018-10-19 16:35:18 +02:00
break ;
case 'extended' :
2018-12-13 15:13:56 +01:00
settings . toolbar = et2 _htmlarea . TOOLBAR _EXTENDED ;
2018-10-19 16:35:18 +02:00
break ;
case 'advanced' :
2018-10-24 14:48:19 +02:00
settings . toolbar = et2 _htmlarea . TOOLBAR _ADVANCED ;
2018-10-19 16:35:18 +02:00
break ;
2014-06-30 23:28:03 +02:00
}
2018-10-24 14:48:19 +02:00
// take rte_toolbar into account if no mode restrictly set from template
if ( rte _toolbar && ! this . mode )
{
var toolbar _diff = et2 _htmlarea . TOOLBAR _LIST . filter ( ( i ) => { return ! ( rte _toolbar . indexOf ( i ) > - 1 ) ; } ) ;
settings . toolbar = et2 _htmlarea . TOOLBAR _ADVANCED ;
toolbar _diff . forEach ( ( a ) => {
let r = new RegExp ( a ) ;
settings . toolbar = settings . toolbar . replace ( r , '' ) ;
} ) ;
}
2018-10-19 16:35:18 +02:00
return settings ;
2012-06-06 06:13:19 +02:00
} ,
destroy : function ( ) {
2018-10-19 16:35:18 +02:00
if ( this . editor )
2013-03-26 16:54:18 +01:00
{
2018-10-19 16:35:18 +02:00
this . editor . destroy ( ) ;
2013-03-26 16:54:18 +01:00
}
2018-10-19 16:35:18 +02:00
this . editor = null ;
this . tinymce = null ;
2018-10-29 15:11:32 +01:00
this . tinymce _container = null ;
2013-10-10 13:57:18 +02:00
this . htmlNode . remove ( ) ;
this . htmlNode = null ;
this . _super . apply ( this , arguments ) ;
2012-06-06 06:13:19 +02:00
} ,
set _value : function ( _value ) {
2013-08-23 17:15:30 +02:00
this . _oldValue = _value ;
2018-10-19 16:35:18 +02:00
if ( this . editor )
{
this . editor . setContent ( _value ) ;
}
else
{
2018-12-12 23:23:13 +01:00
if ( this . options . readonly )
{
this . htmlNode . empty ( ) . append ( _value ) ;
}
else
{
this . htmlNode . val ( _value ) ;
}
2012-06-12 22:50:45 +02:00
}
2018-12-12 23:23:13 +01:00
this . value = _value ;
2012-06-06 06:13:19 +02:00
} ,
getValue : function ( ) {
2018-12-12 23:23:13 +01:00
return this . editor ? this . editor . getContent ( ) : (
this . options . readonly ? this . value : this . htmlNode . val ( )
) ;
2015-02-03 12:11:02 +01:00
} ,
2015-03-31 19:01:25 +02:00
2015-02-03 12:11:02 +01:00
/ * *
* Resize htmlNode tag according to window size
* @ param { type } _height excess height which comes from window resize
* /
resize : function ( _height )
{
2015-04-30 10:07:23 +02:00
if ( _height && this . options . resize _ratio !== '0' )
2015-02-03 12:11:02 +01:00
{
// apply the ratio
_height = ( this . options . resize _ratio != '' ) ? _height * this . options . resize _ratio : _height ;
2015-04-27 11:10:47 +02:00
if ( _height != 0 )
{
2018-10-19 16:35:18 +02:00
if ( this . editor ) // TinyMCE HTML
2015-04-27 11:10:47 +02:00
{
var h = 0 ;
2018-10-19 16:35:18 +02:00
if ( typeof this . editor . iframeElement != 'undefined' && this . editor . editorContainer . clientHeight > 0 )
2015-04-27 11:10:47 +02:00
{
2018-10-19 16:35:18 +02:00
h = ( this . editor . editorContainer . clientHeight + _height ) > 0 ?
( this . editor . editorContainer . clientHeight ) + _height : this . editor . settings . min _height ;
2015-04-27 11:10:47 +02:00
}
2015-04-30 10:07:23 +02:00
else // fallback height size
{
2018-10-19 16:35:18 +02:00
h = this . editor . settings . min _height + _height ;
2015-04-30 10:07:23 +02:00
}
2018-10-19 16:35:18 +02:00
jQuery ( this . editor . editorContainer ) . height ( h ) ;
jQuery ( this . editor . iframeElement ) . height ( h - ( this . editor . editorContainer . getElementsByClassName ( 'tox-toolbar' ) [ 0 ] . clientHeight +
this . editor . editorContainer . getElementsByClassName ( 'tox-statusbar' ) [ 0 ] . clientHeight ) ) ;
2015-04-27 11:10:47 +02:00
}
2018-10-19 16:35:18 +02:00
else // No TinyMCE
2015-08-07 16:18:07 +02:00
{
2015-04-27 11:10:47 +02:00
this . htmlNode . height ( this . htmlNode . height ( ) + _height ) ;
}
}
2015-02-03 12:11:02 +01:00
}
2012-06-06 06:13:19 +02:00
}
2016-02-29 21:40:43 +01:00
} ) ; } ) . call ( this ) ;
2018-10-24 14:48:19 +02:00
et2 _register _widget ( et2 _htmlarea , [ "htmlarea" ] ) ;
// Static class stuff
jQuery . extend ( et2 _htmlarea , {
/ * *
* Array of toolbars
* @ constant
* /
TOOLBAR _LIST : [ 'undo' , 'redo' , 'formatselect' , 'fontselect' , 'fontsizeselect' ,
'bold' , 'italic' , 'strikethrough' , 'forecolor' , 'backcolor' , 'link' ,
'alignleft' , 'aligncenter' , 'alignright' , 'alignjustify' , 'numlist' ,
'bullist' , 'outdent' , 'indent' , 'ltr' , 'rtl' , 'removeformat' , 'code' , 'image' , 'searchreplace'
] ,
/ * *
* arranged toolbars as simple mode
* @ constant
* /
2018-12-13 15:13:56 +01:00
TOOLBAR _SIMPLE : "undo redo|fontselect fontsizeselect | bold italic removeformat forecolor backcolor | " +
"alignleft aligncenter alignright alignjustify | numlist " +
2018-12-13 12:52:10 +01:00
"bullist outdent indent| link image pastetext" ,
2018-10-24 14:48:19 +02:00
/ * *
* arranged toolbars as extended mode
* @ constant
* /
TOOLBAR _EXTENDED : "fontselect fontsizeselect | bold italic strikethrough forecolor backcolor | " +
"link | alignleft aligncenter alignright alignjustify | numlist " +
2018-12-13 15:13:56 +01:00
"bullist outdent indent | removeformat | image" ,
2018-10-24 14:48:19 +02:00
/ * *
* arranged toolbars as advanced mode
* @ constant
* /
TOOLBAR _ADVANCED : "undo redo| formatselect | fontselect fontsizeselect | bold italic strikethrough forecolor backcolor | " +
2018-12-13 15:13:56 +01:00
"link | alignleft aligncenter alignright alignjustify | numlist " +
2018-10-24 14:48:19 +02:00
"bullist outdent indent ltr rtl | removeformat code| image | searchreplace" ,
/ * *
* font size formats
* @ constant
* /
FONT _SIZE _FORMATS : {
pt : "8pt 10pt 12pt 14pt 18pt 24pt 36pt 48pt 72pt" ,
px : "8px 10px 12px 14px 18px 24px 36px 48px 72px"
2018-10-25 18:18:31 +02:00
} ,
/ * *
* language code represention for TinyMCE lang code
* /
LANGUAGE _CODE : {
bg : "bg_BG" , ca : "ca" , cs : "cs" , da : "da" , de : "de" , en : "en_CA" ,
el : "el" , "es-es" : "es" , et : "et" , eu : "eu" , fa : "fa_IR" , fi : "fi" ,
fr : "fr_FR" , hi : "" , hr : "hr" , hu : "hu_HU" , id : "id" , it : "it" , iw : "" ,
ja : "ja" , ko : "ko_KR" , lo : "" , lt : "lt" , lv : "lv" , nl : "nl" , no : "nb_NO" ,
pl : "pl" , pt : "pt_PT" , "pt-br" : "pt_BR" , ru : "ru" , sk : "sk" , sl : "sl_SI" ,
sv : "sv_SE" , th : "th_TH" , tr : "tr_TR" , uk : "en_GB" , vi : "vi_VN" , zh : "zh_CN" ,
"zh-tw" : "zh_TW"
2018-10-24 14:48:19 +02:00
}
} ) ;