artabro/wire/modules/Inputfield/InputfieldTinyMCE/InputfieldTinyMCE.module.php
2024-08-27 11:35:37 +02:00

851 lines
24 KiB
PHP

<?php namespace ProcessWire;
/**
* InputfieldTinyMCE
*
* ProcessWire 3.x, Copyright 2023 by Ryan Cramer
* https://processwire.com
*
* TinyMCE 6.x, Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc.
* https://www.tiny.cloud/docs/tinymce/6/
*
* TinyMCE settings (these are also Field settings)
* ------------------------------------------------
* @property string $plugins Space-separated string of plugins to enable
* @property string $toolbar Space-separated string of tools to show in toolbar
* @property string $contextmenu Space-separated string of tools to show in context menu
* @property string $removed_menuitems Space-separated string of tools to remove from menubar
* @property string $invalid_styles Space-separated string of invalid inline styles
* @property string $menubar Space-separated list of top-level menubar items
* @property int $height Height of editor in pixels
*
* Field/Inputfield settings
* -------------------------
* @property int $inlineMode Use inline mode? 0=Regular editor, 1=Inline editor, 2=Fixed height inline editor
* @property int $lazyMode Use lazy-loading mode? 0=Off, 1=Lazy, 2=Extra lazy
* @property array $toggles Markup toggles, see self::toggle* constants
* @property array $features General features: toolbar, menubar, statusbar, stickybars, spellcheck, purifier, imgUpload, imgResize, pasteFilter
* @property array $headlines Allowed headline types
* @property string $settingsFile Location of optional custom-settings.json settings file (URL relative to PW root URL)
* @property string $settingsField Alternate field to inherit settings from rather than configure settings with this instance.
* @property string $settingsJSON JSON with custom settings that override the defaults
* @property string $styleFormatsCSS Style formats as CSS to parse and apply to style_formats and content_style
* @property array $extPlugins Additional plugins to enable for this field (URL paths from customPluginOptions)
*
* Module settings
* ---------------
* @property string $content_css Basename of content CSS file to use or "custom" to use custom URL (default='wire')
* @property string $content_css_url Applies only if $content_css has value "custom"
* @property string $skin
* @property string $skin_url
* @property string $extPluginOpts Newline separated URL paths (relative to PW root) of extra plugin .js files
* @property string $defaultsFile Location of optional defaults.json file that merges with defaults.json (URL relative to PW root URL)
* @property string $defaultsJSON JSON that merges with the defaults.json for all instances
* @property array $optionals Names of optional settings that can be configured per-field
* @property bool|int $debugMode Makes InputfieldTinyMCE.js use verbose console.log() messages
* @property string $extraCSS Extra CSS for editor, applies to all editors (appended to TinyMCE content_style setting)
* @property string $pasteFilter Rule string of elements and attributes allowed during filtered paste
* @property array $imageFields Names of fields allowed for drag-drop in images
* There are also `$lang_name=packname` settings in multi-lang sites where "name" is lang name and "packname" is lang pack name
*
* Runtime settings
* ----------------
* @property string $configName
* @property-read bool $readonly Automatically set during renderValue mode
* @property-read bool $initialized
* @property array $external_plugins URLs of external plugins, this is also a TinyMCE setting
* @property-read InputfieldTinyMCESettings $settings
* @property-read InputfieldTinyMCEConfigs $configs
* @property-read InputfieldTinyMCETools $tools
* @property-read InputfieldTinyMCEFormats $formats
* @property-read null|bool $renderValueMode
*
*
*/
class InputfieldTinyMCE extends InputfieldTextarea implements ConfigurableModule {
/**
* Get module info
*
* @return array
*
*/
public static function getModuleInfo() {
return array(
'title' => 'TinyMCE',
'summary' => 'TinyMCE rich text editor version ' . self::mceVersion . '.',
'version' => 616,
'icon' => 'keyboard-o',
'requires' => 'ProcessWire>=3.0.200, MarkupHTMLPurifier',
);
}
/**
* TinyMCE version
*
*/
const mceVersion = '6.4.1';
const toggleCleanDiv = 2; // remove <div>s
const toggleCleanP = 4; // remove empty <p> tags
const toggleCleanNbsp = 8; // remove &nbsp; entities
/**
* Default configuration for filtered paste
*
*/
const defaultPasteFilter =
'p,strong,em,hr,br,u,s,h1,h2,h3,h4,h5,h6,ul,ol,li,blockquote,cite,' .
'a[href|id],a[target=_blank],' . // a[rel=nofollow|noopener|noreferrer],' .
'img[src|alt],img[class=align_left|align_right|align_center],' .
'table[border],thead,tbody,tfoot,tr[rowspan],td[colspan],th[colspan],colgroup,col,' .
'sup,sub,figure,figcaption,code,pre,b=strong,i=em';
/**
* Have editor scripts loaded in this request?
*
* @var bool
*
*/
static protected $loaded = false;
/**
* @var MarkupHTMLPurifier|null
*
*/
static protected $purifier = null;
/**
* Name of current JS config key
*
*/
protected $configName = 'default';
/**
* Instances of InputfieldTinyMCE ConfigHelper, Tools, Settings
*
* @var array
*
*/
protected $helpers = array();
/**
* Setting names for field, module, tinymce and more
*
* field: setting names populated by init().
* module: setting names populated by __construct().
* defult: setting names that had the value 'default' set prior to init().
* tinymce: setting names are those native to TinyMCE.
* optionals: settings that can be configured with module OR field.
*
* @var array
*
*/
protected $settingNames = array(
'field' => array(),
'module' => array(),
'default' => array(),
'tinymce' => array(
'skin',
'height',
'plugins',
'toolbar',
'menubar',
'statusbar',
'contextmenu',
'removed_menuitems',
'external_plugins',
'invalid_styles',
'readonly',
'content_css',
'content_css_url', // used when content_css=="custom", not part of tinyMCE
'external_plugins',
'skin_url',
),
'optionals' => array(
'contextmenu' => 'contextmenu',
'menubar' => 'menubar',
'removed_menuitems' => 'removed_menuitems',
'invalid_styles' => 'invalid_styles',
'styleFormatsCSS' => 'styleFormatsCSS',
'settingsJSON' => 'settingsJSON',
'headlines' => 'headlines',
'imageFields' => 'imageFields',
),
);
/**
* Available options for 'features' setting
*
* @var string[]
*
*/
protected $featureNames = array(
'toolbar',
'menubar',
'statusbar',
'stickybars',
'spellcheck',
'purifier',
'document',
'imgUpload',
'imgResize',
'pasteFilter',
);
/**
* False when we should inherit settings from another field
*
* @var bool
*
*/
protected $configurable = true;
/**
* Is field initialized? (i.e. init method already called)
*
* @var bool
*
*/
protected $initialized = false;
/**
* Construct
*/
public function __construct() {
// module settings
$data = array(
'skin' => 'oxide',
'content_css' => 'wire',
'content_css_url' => '',
'defaultsFile' => '',
'defaultsJSON' => '',
'extPluginOptions' => '',
'styleFormatsCSS' => '', // optionals
'extraCSS' => '',
'pasteFilter' => 'default',
'optionals' => array('settingsJSON'),
'debugMode' => false,
);
foreach(array_keys($data) as $key) {
$this->settingNames['module'][$key] = $key;
}
// optionals
$data['headlines'] = array('h1','h2','h3','h4','h5','h5','h6');
$data['menubar'] = 'default';
$data['contextmenu'] = 'default';
$data['removed_menuitems'] = 'default';
$data['invalid_styles'] = 'default';
$data['imageFields'] = array();
$this->data($data);
parent::__construct();
}
/**
* Init Inputfield
*
* Module settings have already been populated at this point.
*
*/
public function init() {
parent::init();
$this->initialized = true;
$this->attr('rows', 15);
$optionals = $this->optionals;
// set module settings that had value 'default' which requires values from getDefaults()
// that we do not want called until the init() state reached (for defaultsJSON)
foreach($this->settingNames['default'] as $key) {
$this->set($key, 'default');
}
$this->settingNames['default'] = array(); // no longer needed
// field settings
$data = array(
'contentType' => FieldtypeTextarea::contentTypeHTML,
'inlineMode' => 0,
'lazyMode' => 1, // 0=off, 1=load when visible, 2=load on click
'features' => array(
'toolbar',
'menubar',
'statusbar',
'stickybars',
'purifier',
'imgUpload',
'imgResize',
'pasteFilter',
),
'settingsFile' => '',
'settingsField' => '',
'settingsJSON' => '',
'styleFormatsCSS' => '',
'extPlugins' => array(),
'toggles' => array(
// self::toggleCleanDiv,
// self::toggleCleanNbsp,
// self::toggleCleanP,
),
);
if(!in_array('styleFormatsCSS', $optionals)) unset($data['styleFormatsCSS']);
if(!in_array('settingsJSON', $optionals)) unset($data['settingsJSON'], $data['settingsFile']);
$this->data($data);
$this->settingNames['field'] = array_keys($data);
$this->settingNames['field'][] = 'headlines'; // optionals
// tinymce settings (from field or module)
$defaults = $this->settings->getDefaults();
$settings = array();
foreach($this->settingNames['tinymce'] as $key) {
// skip over module-wide settings that match TinyMCE setting names
if($key === 'skin' || $key === 'skin_url') continue;
if($key === 'content_css' || $key === 'content_css_url') continue;
if(isset($this->settingNames['optionals'][$key]) && !in_array($key, $optionals)) {
// setting only configurable at module level
$this->settingNames['module'][] = $key;
continue;
}
$settings[$key] = $defaults[$key];
}
$this->data($settings);
}
/**
* Use the named feature?
*
* @param string $name
* @return bool
*
*/
public function useFeature($name) {
if($name === 'inline') return $this->inlineMode > 0;
return in_array($name, $this->features);
}
/**
* Return path or URL to TinyMCE files
*
* @param bool $getUrl
* @return string
*
*/
public function mcePath($getUrl = false) {
$config = $this->wire()->config;
$path = ($getUrl ? $config->urls($this) : __DIR__ . '/');
return $path . 'tinymce-' . self::mceVersion . '/';
}
/**
* Set configuration name used to store settings in ProcessWire.config JS
*
* i.e. ProcessWire.config.InputfieldTinyMCE.settings.[configName].[settingName]
*
* @param string $configName
* @return $this
*
*/
public function setConfigName($configName) {
$this->configName = $configName;
return $this;
}
/**
* Get configuration name used to store settings in ProcessWire.config JS
*
* i.e. ProcessWire.config.InputfieldTinyMCE.settings.[configName].[settingName]
*
* @return string
*
*/
public function getConfigName() {
return $this->configName;
}
/**
* Get or set configurable state
*
* - True if Inputfield is configurable (default state).
* - False if it is required that another field be used ($settingsField) to pull settings from.
* - Note this is completely unrelated to the $configName property.
*
* @param bool $set
* @return bool
*
*/
public function configurable($set = null) {
if(is_bool($set)) $this->configurable = $set;
return $this->configurable;
}
/**
* Get
*
* @param $key
* @return array|mixed|string|null
*
*/
public function get($key) {
switch($key) {
case 'tools':
case 'settings':
case 'configs':
case 'formats': return $this->helper($key);
case 'initialized': return $this->initialized;
case 'configName': return $this->configName;
}
return parent::get($key);
}
/**
* Set
*
* @param $key
* @param $value
* @return self
*
*/
public function set($key, $value) {
if($this->initialized) {
if(isset($this->settingNames['optionals'][$key])) {
// do not set optionals property not allowed for field configuration
// if not specifically selected in module settings
if(!in_array($key, $this->optionals)) return $this;
}
} else {
// when not yet initialized, avoid processing any settings with value 'default'
// since this will prematurely load the getDefaults() before we are ready
if($value === 'default') {
$this->settingNames['default'][$key] = $key;
return $this;
}
}
if($key === 'toolbar' && is_string($value)) {
if(strpos($value, ',') !== false) {
// $value = $this->configs()->ckeToMceToolbar($value); // convert CKE toolbar
return $this; // ignore CKE toolbar (which has commas in it)
}
if($value === 'default') {
$value = $this->settings->getDefaults($key);
} else {
$value = $this->tools->sanitizeNames($value);
}
} else if($key === 'invalid_styles') {
if($value === 'default') $value = $this->settings->getDefaults($key);
} else if($key === 'plugins' || $key === 'contextmenu' || $key === 'removed_menuitems' || $key === 'menubar') {
if($value === 'default') {
$value = $this->settings->getDefaults($key);
} else {
$value = $this->tools->sanitizeNames($value);
}
} else if($key === 'configName') {
return $this->setConfigName($value);
}
return parent::set($key, $value);
}
/**
* Get styles to add in <head>
*
* @return string
*
*/
public function getExtraStyles() {
$a = array();
$skin = $this->skin;
if(strpos($skin, 'dark') !== false && strpos($this->content_css, 'dark') === false) {
// ensure some menubar/toolbar labels are not black-on-black in dark skin + light content_css
// this was necessary as of TinyMCE 6.2.0
$a[] = "body .tox-collection__item-label > *:not(code):not(pre) { color: #eee !important; }";
}
if($skin && $skin != 'custom') {
// make dialogs use PW native colors for buttons (without having to make a custom skin for it)
$buttonSelector = ".tox-dialog .tox-button:not(.tox-button--secondary):not(.tox-button--icon)";
$a[] = "$buttonSelector { background-color: #3eb998; border-color: #3eb998; }";
$a[] = "$buttonSelector:hover { background-color: #e83561; border-color: #e83561; }";
}
return implode(' ', $a);
}
/**
* Render ready that only needs one call for entire request
*
*/
protected function renderReadyOnce() {
$modules = $this->wire()->modules;
$adminTheme = $this->wire()->adminTheme;
$class = $this->className();
$config = $this->wire()->config;
$mceUrl = $this->mcePath(true);
$config->scripts->add($mceUrl . 'tinymce.min.js');
$jQueryUI = $modules->get('JqueryUI'); /** @var JqueryUI $jQueryUI */
$jQueryUI->use('modal');
$css = $this->getExtraStyles();
if($css && $adminTheme) {
// note: using a body class (rather than <style>) interferes with TinyMCE inline mode
// making it leave toolbar/menubar active even when moving out of the field
$adminTheme->addExtraMarkup('head', "<style>$css</style>");
}
$js = array(
'settings' => array(
'default' => $this->settings->prepareSettingsForOutput($this->settings->getDefaults())
),
'labels' => array(
// translatable labels for pwimage and pwlink plugins
'selectImage' => $this->_('Select image'),
'editImage' => $this->_('Edit image'),
'captionText' => $this->_('Your caption text here'),
'savingImage' => $this->_('Saving image'),
'cancel' => $this->_('Cancel'),
'insertImage' => $this->_('Insert image'),
'selectAnotherImage' => $this->_('Select another'),
'insertLink' => $this->_('Insert link'),
'editLink' => $this->_('Edit link'),
),
'pwlink' => array(
// settings specific to pwlink plugin
'classOptions' => $this->tools->linkConfig('classOptions')
),
'pasteFilter' => $this->tools->getPasteFiltersForJS(),
'debug' => (bool) $this->debugMode,
);
$config->js($class, $js);
}
/**
* Render ready
*
* @param Inputfield|null $parent
* @param bool $renderValueMode
* @return bool
*
*/
public function renderReady(Inputfield $parent = null, $renderValueMode = false) {
if(!self::$loaded) {
$this->renderReadyOnce();
self::$loaded = true;
}
$this->renderValueMode = $renderValueMode;
$settingsField = $this->settingsField;
if($settingsField) {
$this->configName = (string) $settingsField;
$settingsField = $this->settings->applySettingsField($settingsField);
}
$replaceTools = array();
$upload = $this->useFeature('imgUpload');
$imageField = $upload ? $this->tools->getImageField() : null;
$field = $settingsField instanceof Field ? $settingsField : $this->hasField;
$addSettings = array();
if($this->inlineMode > 0 || $this->lazyMode > 1) {
$cssFile = $this->settings->getContentCssUrl();
$this->wire()->config->styles->add($cssFile);
}
if($imageField) {
// custom attributes for images
$this->addClass('InputfieldHasUpload', 'wrapClass');
$this->wrapAttr('data-upload-page', $this->hasPage->id);
$this->wrapAttr('data-upload-field', $imageField->name);
} else {
// disable drag-drop "data:base64..." images
$addSettings['paste_data_images'] = false;
if(!$this->hasPage) {
// pwimage plugin requires a page editor
$page = $this->wire()->page;
$replaceTools['pwimage'] = '';
if($page->template->name !== 'admin' && !$page->get('_PageFrontEdit')) {
// pwlink requires admin
$replaceTools['pwlink'] = 'link';
}
}
}
if(count($replaceTools)) {
foreach($replaceTools as $find => $replace) {
$this->plugins = str_replace($find, $replace, $this->plugins);
$this->toolbar = str_replace($find, $replace, $this->toolbar);
$this->contextmenu = str_replace($find, $replace, $this->contextmenu);
$a = $this->external_plugins;
if(isset($a[$find])) {
unset($a[$find]);
$this->external_plugins = $a;
}
}
}
if($field && $field->type instanceof FieldtypeTextarea) {
if(!$this->configName || $this->configName === 'default') {
$this->configName = $field->name;
}
}
$this->settings->applyRenderReadySettings($addSettings);
return parent::renderReady($parent, $renderValueMode);
}
/**
* Render Inputfield
*
* @return string
*
*/
public function ___render() {
if($this->inlineMode && $this->tools->purifier()) {
// Inline editor
$out = $this->renderInline();
} else {
// Normal editor
$out = $this->renderNormal();
}
return $out;
}
/**
* Render normal/classic editor
*
* @return string
*
*/
protected function renderNormal() {
$this->addClass('InputfieldTinyMCEEditor InputfieldTinyMCENormal');
$out = parent::___render();
if($this->lazyMode > 1) {
$height = ((int) $this->height) . 'px';
$out =
"<div class='InputfieldTinyMCEPlaceholder' style='height:$height'>" .
"<div class='mce-content-body'></div>" .
"</div>$out";
} else {
$out .= $this->renderInitScript();
}
return $out;
}
/**
* Render inline editor
*
* @return string
*
*/
protected function renderInline() {
$attrs = $this->getAttributes();
$inlineFixed = (int) $this->inlineMode > 1;
$value = $this->tools->purifyValue($attrs['value']);
unset($attrs['value'], $attrs['type'], $attrs['rows']);
$attrs['class'] = 'InputfieldTinyMCEEditor InputfieldTinyMCEInline mce-content-body';
$attrs['tabindex'] = '0';
if($inlineFixed && $this->height) {
$height = ((int) $this->height) . 'px';
$style = isset($attrs['style']) ? $attrs['style'] : '';
$attrs['style'] = "overflow:auto;height:$height;$style";
}
$attrStr = $this->getAttributesString($attrs);
$out = "<div $attrStr>$value</div>";
// optionally turn off lazy-loading mode for inline
// if(!$this->lazyMode) $out .= $this->renderInitScript();
return $out;
}
/**
* Render script to init editor
*
* @return string
*
*/
protected function renderInitScript() {
$id = $this->attr('id');
$script = 'script';
$js = "InputfieldTinyMCE.init('#$id', 'module.render'); ";
return "<$script>$js</$script>";
}
/**
* Render non-editable value
*
* @return string
*
*/
public function ___renderValue() {
if(wireInstanceOf($this->wire->process, 'ProcessPageEdit')) {
$this->renderValueMode = true; // should be set already, but just in case
$out = $this->render();
} else {
$out =
"<div class='InputfieldTextareaContentTypeHTML mce-content-body'>" .
$this->wire()->sanitizer->purify($this->val()) .
"</div>";
}
return $out;
}
/**
* Process input
*
* @param WireInputData $input
* @return $this
*
*/
public function ___processInput(WireInputData $input) {
$settingsField = $this->settingsField;
if($settingsField) $this->settings->applySettingsField($settingsField);
$id = $this->attr('id');
$name = $this->attr('name');
$useName = $name;
$rename = $this->inlineMode && $id && $id !== $name;
if($rename) {
// in inlineMode TinyMCE uses id attribute for input name
// $useName = "Inputfield_$name";
$useName = $id;
$this->attr('name', $useName);
}
$value = $input->$useName;
$valuePrevious = $this->val();
if($value !== null && $value !== $valuePrevious && !$this->readonly) {
parent::___processInput($input);
$value = $this->tools->purifyValue($value);
if($value !== $valuePrevious) {
$this->val($value);
$this->trackChange('value');
}
}
if($rename) {
$this->attr('name', $name);
}
return $this;
}
/**
* Get all configurable setting names
*
* @param array|string $types Types to get, one or more of: 'tinymce', 'field', 'module', 'optionals'
* @return string[]
* @throws WireException if given unknown setting type
*
*/
public function getSettingNames($types) {
if(!is_array($types)) $types = explode(' ', $types);
$a = array();
if(empty($types)) $types = array_keys($this->settingNames);
foreach($types as $type) {
if(empty($type)) continue;
if(!isset($this->settingNames[$type])) {
throw new WireException("Unknown setting type: $type");
}
$a = array_merge($a, array_values($this->settingNames[$type]));
}
return $a;
}
/**
* Add an external plugin .js file
*
* @param string $file File must be .js file relative to PW installation root, i.e. /site/templates/mce/myplugin.js
* @throws WireException
*
*/
public function addPlugin($file) {
$this->configs->addPlugin($file);
}
/**
* Remove an external plugin .js file
*
* @param string $file File must be .js file relative to PW installation root, i.e. /site/templates/mce/myplugin.js
* @return bool
*
*/
public function removePlugin($file) {
return $this->configs->removePlugin($file);
}
/**
* Get directionality, either 'ltr' or 'rtl'
*
* @return string
*
*/
public function getDirectionality() {
return $this->_x('ltr', 'language-direction'); // change to 'rtl' for right-to-left languages
}
/**
* Get helper
*
* @param string $name
* @return InputfieldTinyMCEClass
*
*/
public function helper($name) {
if(empty($this->helpers[$name])) {
$class = $this->className() . ucfirst($name);
require_once(__DIR__ . "/InputfieldTinyMCEClass.php");
require_once(__DIR__ . "/$class.php");
$class = "\\ProcessWire\\$class";
$this->helpers[$name] = new $class($this);
}
return $this->helpers[$name];
}
/**
* Get Inputfield configuration settings
*
* @return InputfieldWrapper
*
*/
public function ___getConfigInputfields() {
$inputfields = parent::___getConfigInputfields();
$this->configs->getConfigInputfields($inputfields);
return $inputfields;
}
/**
* Module config
*
* @param InputfieldWrapper $inputfields
*
*/
public function getModuleConfigInputfields(InputfieldWrapper $inputfields) {
$this->configs->getModuleConfigInputfields($inputfields);
}
}