'Front-End Page Editor', 'summary' => 'Enables front-end editing of page fields.', 'version' => 5, 'author' => 'Ryan Cramer', 'license' => 'MPL 2.0', 'icon' => 'cube', 'autoload' => true, 'permissions' => array( 'page-edit-front' => 'Use the front-end page editor', ) ); } /** * Mode for debugging/developing this module, should always be false in production * */ const debug = false; /** * Names of fields applying front end editing to * * @var array of field names * */ protected $inlineEditors = array(); /** * Names of fields applying modal editing to * * @var array of field names, indexed by "page_id-field_id" * */ protected $modalEditors = array(); /** * Page this front-end editor is for * * @var Page|null * */ protected $page = null; /** * Whether or not the editor should be applied for any requested fields * * @var bool * */ protected $inlineEditorActive = true; /** * The field ID of an field that is allowed for edits, even if not in inlineEditFields * * @var int * */ protected $inlineEditField = 0; /** * Whether or not the editor is allowed for this request * * @var bool * */ protected $editorAllowed = false; /** * Priority of Page::render hook * * @var int * */ protected $renderHookPriority = 102; /** * Priority for Fieldtype::formatValue hook * * @var int * */ protected $formatHookPriority = 200; /** * Editor number for setting ID attributes on .pw-edit divs * * @var int * */ protected $editorNum = 0; public function __construct() { parent::__construct(); // allowed base Fieldtypes for inline editing (all others go modal) $this->set('inlineAllowFieldtypes', array( 'FieldtypeText', 'FieldtypeInteger' )); } public function init() { if($this->wire()->config->ajax && $this->wire()->input->post('action') === 'PageFrontEditSave') { $this->addHookAfter('ProcessWire::ready', $this, 'inlineSaveEdits'); } } /** * Ready state, attach hooks * */ public function ready() { $page = $this->getPage(); if(!$page) $page = $this->wire()->page; // check if we should allow editor for current page if($page->template->name === 'admin') return; $this->addHookBefore('Page::edit', $this, 'hookPageEditor'); $this->addHook('Page::editor', $this, 'hookPageEditor'); if(isset($_GET['livepreview'])) return; // PWPD $contentType = $page->template->contentType; if($contentType && $contentType != 'html' && $contentType != 'text/html') return; $user = $this->wire()->user; if($user->isGuest() || ($page->hasStatus(Page::statusDraft) && !$page->get('_isDraft')) || !$page->editable() || !$user->hasPermission('page-edit-front', $page)) { // replace any tags or "edit" attributes in the markup $this->addHookAfter('Page::render', $this, 'hookPageRenderNoEdit', array( 'priority' => $this->renderHookPriority )); return; } // editing is allowed $this->editorAllowed = true; $this->setPage($page); $input = $this->wire()->input; $config = $this->wire()->config; $templates = $this->wire()->templates; if($config->ajax && $input->post('action') == 'PageFrontEditSave') { // skip, this is handled by another hook } else if($templates->get('admin')->https == 1 && !$config->https && !$config->noHTTPS && $page->template->https != -1) { // hooks allowed, but we need the same scheme as admin $url = $input->httpUrl(true); if(strpos($url, 'http://') === 0) { $url = str_replace('http://', 'https://', $url); $this->wire()->session->redirect($url, false); } } else { // we are allowing this page to use the editor, so attach hooks foreach($this->inlineAllowFieldtypes as $fieldtypeClass) { $this->addHookAfter("$fieldtypeClass::formatValue", $this, 'inlineHookFormatValue', array( 'priority' => $this->formatHookPriority )); } $this->addHookAfter('Page::render', $this, 'hookPageRender', array( 'priority' => $this->renderHookPriority )); } } /** * Get the page being edited or null if not yet set * * #pw-hooker * * @return Page|null * @since 3.0.208 * */ public function ___getPage() { return $this->page; } /** * Set the page being edited * * @param Page $page * */ public function setPage(Page $page) { $page->setQuietly('_PageFrontEdit', true); $this->page = $page; } /** * Is the given page and field editable on the front-end? * * @param Page $page * @param Field $field * @return bool * */ public function inlineIsEditable(Page $page, Field $field) { if(!$this->inlineEditorActive || !$this->editorAllowed) return false; if(!in_array($field->id, $this->inlineEditFields) && $field->id != $this->inlineEditField) return false; if($this->inlineLimitPage && $page->id != $this->page->id && in_array($field->id, $this->inlineEditFields)) return false; return $this->isEditable($page, $field); } /** * Is the given page and field saveable on the front-end? * * @param Page $page * @param Field $field * @return bool * */ public function inlineIsSaveable(Page $page, Field $field) { if(!$this->editorAllowed) return false; if(!$this->inlineSupported($field)) return false; // if(!in_array($field->id, $this->inlineEditFields) && $field->id != $this->inlineEditField) return false; return $this->isEditable($page, $field); } /** * Is the given page and field front-end editable? * * @param Page $page * @param Field $field * @return bool * */ protected function isEditable(Page $page, Field $field) { $user = $this->wire()->user; if($page->className() != 'Page' && wireInstanceOf($page, 'RepeaterPage')) { /** @var RepeaterPage $page */ $forPage = $page->getForPage(); $forField = $page->getForField(); if(!$user->hasPermission('page-edit-front', $forPage)) return false; if(!$forPage->editable($forField)) return false; } else { if(!$user->hasPermission('page-edit-front', $page)) return false; } if(!$page->editable($field)) return false; return true; } /** * Is inline mode supported for the given field? * * @param Field $field * @return bool * */ public function inlineSupported(Field $field) { $classParents = wireClassParents($field->type); $className = $field->type->className(); $supported = false; foreach($this->inlineAllowFieldtypes as $allowClass) { if($allowClass == $className) { $supported = true; break; } else if(in_array($allowClass, $classParents)) { $test = $field->type->getBlankValue(new NullPage(), $field); if(!is_object($test)) $supported = true; break; } } return $supported; } /** * Add Page::edit() method with this hook * * This method enables any of the following: * - Retrieving the current editor status (enabled or disabled), by supplying no arguments. * - Setting the current editor status by supplying a bool (true or false). * - Retrieving an editable value ready for output by supplying a field name. * - Retrieving a value in non-editable form by supplying a field name and false as 2nd argument. * * Examples: * $isActive = $page->edit(); // returns whether editor is currently active * $page->edit(true); // enables the editor (false disables) * $value = $page->edit('field_name'); // retrieve editable value, if field_name is editable * $value = $page->edit('field_name', false); // retrieve non-editable formatted value value * $value = $page->edit('field_name', 'markup'); // make editable region with markup (inline or modal) * $value = $page->edit('field_name', 'markup', true); // make editable region, forcing modal * * @param HookEvent $event * */ public function hookPageEditor(HookEvent $event) { /** @var Page $page */ $page = $event->object; $arg1 = $event->arguments(0); $arg2 = $event->arguments(1); $arg3 = $event->arguments(2); $livePreview = isset($_GET['livepreview']); // PWPD $event->replace = true; if(is_null($arg1)) { // no arguments specified $event->return = $livePreview ? false : $this->inlineEditorActive; return; } if(is_bool($arg1)) { // set editor active state if(!$livePreview) $this->inlineEditorActive = $arg1; $event->return = $page; return; } if($arg2 === false) { // request to retrieve value without editor $editorActive = $this->inlineEditorActive; $this->inlineEditorActive = false; $event->return = $page->getFormatted($arg1); $this->inlineEditorActive = $editorActive; return; } // at this point, we require arg1 to be the field name if(!is_string($arg1)) { $event->return = 'Invalid argument to $page->edit()'; return; } // if given field name doesn't map to a custom field, delegate the request to $page instead $renderField = false; $field = $this->wire()->fields->get($arg1); if(!$field) { if(strpos($arg1, '_') === 0 && substr($arg1, -1) === '_') { $renderField = true; $arg1 = substr($arg1, 1, -1); $field = $this->wire()->fields->get($arg1); if(!$field) $arg1 = "_{$arg1}_"; // restore if it didn't resolve to a field } if(!$field) { $event->return = $page->get($arg1); return; } } if(is_string($arg2)) { // make a markup string editable for indicated field, arg1 is field name, arg2 is markup if($page->editable($field->name) && !$livePreview) { // field is editable, return markup wrapped in modal editor $forceModal = $arg3 === true; if(!$forceModal && $this->inlineSupported($field)) { $this->inlineEditField = $field->id; $event->return = $this->inlineRenderEditor($page, $field, $arg2); $this->inlineEditField = 0; } else { $event->return = $this->modalRenderEditor($page, array($field), $arg2); } } else { // field is not editable by user, just return original markup $event->return = $arg2; } return; } // now try for inline editable field if(!$livePreview && $this->inlineIsSaveable($page, $field)) { // if inline can be used, use it... $editorActive = $this->inlineEditorActive; $this->inlineEditorActive = true; if(!in_array($field->id, $this->inlineEditFields)) $this->inlineEditField = $field->id; $event->return = $renderField ? $page->renderField($field->name) : $page->getFormatted($field->name); $this->inlineEditorActive = $editorActive; $this->inlineEditField = 0; } else { // ...otherwise just return the formatted value $event->return = $renderField ? $page->renderField($field->name) : $page->getFormatted($field->name); } } /** * Hook after Page::render for when we are supporting editable regions * * @param HookEvent $event * */ public function hookPageRender(HookEvent $event) { $this->editorNum = 0; $out = $event->return; if(stripos($out, 'editRegionTag") !== false; // i.e. $hasEditAttr = strpos($out, " $this->editRegionAttr=") !== false; // i.e.
if(strpos($out, "id=pw-edit-") === false && !$hasEditTags && !$hasEditAttr) return; /** @var Page $page */ $page = $event->object; if(!$this->editorAllowed) { $this->hookPageRenderNoEdit($event); return; } // parse tags $numEditable = 0; if($hasEditTags) $numEditable += $this->populateEditTags($page, $out); if($hasEditAttr) $numEditable += $this->populateEditAttrs($page, $out); $numEditable += count($this->inlineEditors); if(!$numEditable) { $event->return = $out; return; } header('X-Frame-Options: SAMEORIGIN'); // bundle in any needed javascript files and related assets if(stripos($out, '')) { $out = str_ireplace("", $this->renderAssets() . "", $out); } else { $out .= $this->renderAssets(); } $event->return = $out; } /** * Page::render for situations where the page is NOT editable * * This removes any tags. * * @param HookEvent $event * */ public function hookPageRenderNoEdit(HookEvent $event) { $out = $event->return; $tag = $this->editRegionTag; $attr = $this->editRegionAttr; $hasEditTags = strpos($out, "<$tag") !== false; $hasEditAttr = strpos($out, "$attr=") !== false; if($hasEditTags) { // remove modal edit tags $out = preg_replace('!]*>|>)\s*!is', '', $out); } if($hasEditAttr) { // remove modal edit attributes $out = preg_replace('!(<[^>]+?)\s' . $attr . '=["\']?[^"\'\s>]*["\']?!is', '$1', $out); } if($hasEditTags || $hasEditAttr) { // update markup $event->return = $out; } } /** * Populate tags in the markup * * Supported formats (one or more fields on current page): * ... * ... * ... * * Supported formats (one or more fields, with specific page "123"): * ... * ... * ... * ... * * @param Page $page * @param string $out * @param bool|null $editable * @return int Number of tags populated * */ protected function populateEditTags(Page $page, &$out, $editable = null) { $sanitizer = $this->wire()->sanitizer; $pages = $this->wire()->pages; $fields = $this->wire()->fields; $tag = $this->editRegionTag; if(!preg_match_all('!<' . $tag . '([^>]+)>(.*?)!is', $out, $matches)) return 0; if(is_null($editable)) $editable = $page->editable(); $numReplaced = 0; $numEditable = 0; foreach($matches[0] as $key => $fullMatch) { $names = ''; $pageID = $page->id; $attrs = explode(' ', $matches[1][$key]); $markup = $matches[2][$key]; if(strpos($markup, 'pw-edit-') !== false) { $out = str_replace($fullMatch, $markup, $out); continue; } foreach($attrs as $attr) { $attr = trim($attr); if(empty($attr)) continue; if(strpos($attr, '=') !== false) { list($attrName, $attrValue) = explode('=', $attr); $attrName = trim($attrName, ' '); $attrValue = trim($attrValue, ' "\''); if(in_array($attrName, array('name', 'names', 'field', 'fields'))) { $names = $attrValue; } else if($attrName == 'page') { $pageID = $attrValue; } } else if(empty($names)) { $names = $attr; } if(strpos($names, ':') !== false) { list($pageID, $names) = explode(':', $names); } else if(strpos($names, '.') !== false) { list($pageID, $names) = explode('.', $names); } } if(ctype_digit($names) && !ctype_digit($pageID)) { list($names, $pageID) = array($pageID, $names); // swap order detected } $foundFields = array(); $inlineSupported = false; $p = new NullPage(); if($names) { $p = $page; if($pageID != $page->id && $pageID != $page->path) { if(ctype_digit($pageID)) { $pageID = (int) $pageID; } else { $pageID = $sanitizer->path($pageID); } $p = $pages->get($pageID); if(!$p->id) $p = $page; } foreach(explode(',', $names) as $name) { $name = trim($name); $field = $fields->get($name); if($editable && $p->editable($name)) { $foundFields[$name] = $field; if($this->inlineSupported($field)) $inlineSupported = true; } } } if(count($foundFields) == 1 && $inlineSupported) { $out = str_replace($fullMatch, $this->inlineRenderEditor($p, reset($foundFields), $markup), $out); $numEditable++; } else if(count($foundFields)) { $out = str_replace($fullMatch, $this->modalRenderEditor($p, $foundFields, $markup), $out); $numEditable++; } else { $out = str_replace($fullMatch, $markup, $out); } $numReplaced++; } return $numEditable; } /** * Update "edit" attributes for modal editing * * @param Page $page * @param $out * @return int * */ protected function populateEditAttrs(Page $page, &$out) { $sanitizer = $this->wire()->sanitizer; $fields = $this->wire()->fields; $pages = $this->wire()->pages; $numEditable = 0; $editRegionAttr = $this->editRegionAttr; if(stripos($out, " $editRegionAttr=") === false) return $numEditable; // tag attr1 data attr2 $regex = '!<([a-z0-9]+)([^>]*?)\s+' . $editRegionAttr . '=["\']?([^\s>"\']+)["\']?([^>]*)>!is'; if(!preg_match_all($regex, $out, $matches)) return $numEditable; foreach($matches[0] as $key => $fullMatch) { $tag = $matches[1][$key]; $attr = $matches[2][$key]; $attr .= trim((strlen($attr) ? ' ' : '') . $matches[4][$key]); $data = $matches[3][$key]; if(strpos($data, ':') !== false) { list($pageID, $fieldNames) = explode(':', $data); } else if(strpos($data, '.') !== false) { list($pageID, $fieldNames) = explode('.', $data); } else { list($pageID, $fieldNames) = array(0, ''); } if($pageID && $fieldNames) { if(ctype_digit($fieldNames) && !ctype_digit($pageID)) { list($pageID, $fieldNames) = array($fieldNames, $pageID); // swap order detected } // check if pageID is actually a page path $pageID = ctype_digit($pageID) ? (int) $pageID : $sanitizer->path($pageID); if(!$pageID) continue; // skip $editPage = $pages->get($pageID); if(!$editPage->id) continue; // skip } else { $fieldNames = $data; $editPage = $page; } $fieldNames = explode(',', $fieldNames); $fieldIDs = array(); foreach($fieldNames as $k => $v) { $fieldName = $sanitizer->fieldName(trim($v)); $field = $fields->get($fieldName); if(!$field || !$editPage->editable($fieldName)) { unset($fieldNames[$k]); } else { $fieldIDs[] = $field->id; } } if(!count($fieldNames)) continue; $fieldNames = implode(',', $fieldNames); $editURL = $editPage->editUrl() . "&fields=$fieldNames&modal=1"; $modalID = 'pw-edit-modal-' . $this->getModalID($page, $fieldIDs); if(strpos(" $attr", " id=") === false) { $attr = "id=$modalID $attr"; } else { $attr = "data-id=$modalID $attr"; } $attr .= " " . "data-class='pw-edit-attr pw-edit-modal pw-modal pw-modal-dblclick' " . "data-href='$editURL' " . "data-fields='$fieldNames' " . "data-buttons='button.ui-button[type=submit]' " . "data-autoclose=''"; $newTag = "<$tag " . trim($attr) . ">"; $out = $this->strReplaceOne($fullMatch, $newTag, $out); $this->editorNum++; $numEditable++; } return $numEditable; } /** * Hook to Fieldtype::formatValue * * Updates formatted values to include inline markup needed for the editor. * * @param HookEvent $event * */ public function inlineHookFormatValue(HookEvent $event) { $field = $event->arguments(1); if(!in_array($field->id, $this->inlineEditFields) && $field->id != $this->inlineEditField) return; $page = $event->arguments(0); $formatted = $event->return; if(is_object($formatted)) $formatted = (string) $formatted; if(empty($formatted)) return; if(!$this->inlineIsEditable($page, $field)) return; if(!$this->inlineEditorActive) return; $event->return = $this->inlineRenderEditor($page, $field, $formatted); } /** * Render markup output for javascripts and editor interface * * @return string * */ public function renderAssets() { $sanitizer = $this->wire()->sanitizer; $modules = $this->wire()->modules; $config = $this->wire()->config; $input = $this->wire()->input; $user = $this->wire()->user; $scripts = array(); $className = $this->className(); $css = ''; $cssFiles = array(); $draft = (int) $input->get('draft'); $adminTheme = $user->admin_theme; if($modules->isInstalled('InputfieldCKEditor')) { $modules->includeModule('InputfieldCKEditor'); $ckeditorUrl = $config->urls('InputfieldCKEditor') . 'ckeditor-' . constant('\ProcessWire\InputfieldCKEditor::CKEDITOR_VERSION') . '/'; } else { $ckeditorUrl = ''; } if($modules->isInstalled('InputfieldTinyMCE')) { $inputfield = $modules->get('InputfieldTinyMCE'); if(method_exists($inputfield, 'getExtraStyles')) $css .= $inputfield->getExtraStyles(); $tinymceInputfieldUrl = $config->urls('InputfieldTinyMCE'); $tinymceUrl = $tinymceInputfieldUrl . 'tinymce-' . constant('\ProcessWire\InputfieldTinyMCE::mceVersion'); $tinymceFile1 = $tinymceUrl . '/tinymce.min.js'; $tinymceFile2 = $tinymceInputfieldUrl . 'InputfieldTinyMCE.js'; $cssFiles[] = $tinymceInputfieldUrl . 'InputfieldTinyMCE.css'; } else { $tinymceFile1 = ''; $tinymceFile2 = ''; $tinymceUrl = ''; } $configJS = $config->js(); $configJS['modals'] = $config->modals; $configJS['urls'] = array( 'admin' => $config->urls->admin, ); $configJS['PageFrontEdit'] = array( 'labels' => array( 'cancelConfirm' => $this->_('Are you sure you want to cancel?') ), 'files' => array( 'modal' => $config->urls('JqueryUI') . 'modal.min.js', 'ckeditor' => $ckeditorUrl . 'ckeditor.js', 'tinymce1' => $tinymceFile1, // tinymce.min.js 'tinymce2' => $tinymceFile2, // InputfieldTinyMCE.js 'css' => ($config->urls($className)) . "$className.css?nocache" . mt_rand(), 'fa' => $config->urls->adminTemplates . "styles/font-awesome/css/font-awesome.min.css", ), 'adminTheme' => $adminTheme ? $adminTheme : 'AdminThemeDefault', 'pageURL' => $this->wire()->page->url . ($draft ? "?draft=$draft" : "") ); $scripts[] = "window.CKEDITOR_BASEPATH = '$ckeditorUrl';" . "window.TINYMCE_BASEURL = '$tinymceUrl';"; $scripts[] = $config->urls($className) . $className . 'Load.js'; if(self::debug && defined('JSON_PRETTY_PRINT')) { $configJSON = json_encode($configJS, JSON_PRETTY_PRINT); // for debugging } else { $configJSON = json_encode($configJS); } $scripts[] = "var _pwfe={config:$configJSON};" . "if(typeof ProcessWire == 'undefined'){" . "var ProcessWire=_pwfe;" . "}else{" . "for(var _pwfekey in _pwfe.config) ProcessWire.config[_pwfekey]=_pwfe.config[_pwfekey];" . "}" . "_pwfe=null;" . "if(typeof config == 'undefined') var config=ProcessWire.config;"; // legacy $loadItems = array(); // jQuery $loadItems[] = array( 'test' => "function() { return (typeof jQuery == 'undefined'); }", 'file' => $config->urls('JqueryCore') . 'JqueryCore.js', 'after' => "function() { " . "jQuery.noConflict(); " . "}" ); // jQuery UI $loadItems[] = array( 'test' => "function() { " . "jQuery('[data-class^=pw-edit-attr]').each(function() { " . "jQuery(this).addClass(jQuery(this).attr('data-class')); " . "});" . "return (typeof jQuery.ui == 'undefined'); " . "}", 'file' => $config->urls('JqueryUI') . 'JqueryUI.js', ); // add in our PageFrontEdit.js file $loadItems[] = array( 'file' => ($config->urls($className)) . $className . '.js?nc=' . filemtime(dirname(__FILE__) . "/$className.js") ); $loadItemsJSON = "{$className}Load(" . json_encode($loadItems) . ");"; $loadItemsJSON = str_replace(array('"function(', '}"'), array('function(', '}'), $loadItemsJSON); $scripts[] = $loadItemsJSON; // button labels $saveLabel = $this->_('Save'); $savingLabel = $this->_('Saving…'); $savedLabel = $this->_('Saved!'); $cancelLabel = $this->_('Cancel'); // render the scripts to markup $out = ''; foreach($scripts as $script) { if(strpos($script, ' ') !== false || strpos($script, '(') !== false) { $out .= ""; } else { $out .= ""; } if(self::debug) $out .= "\n"; } // render the editor interface buttons $lang = $user->language; $lang = $lang ? $lang->id : 0; $class = "pw-edit-buttons pw-edit-buttons-type-$this->buttonType pw-edit-buttons-location-$this->buttonLocation"; $editURL = $this->page->editUrl(); $viewURL = $sanitizer->entities($input->url(true)); $out .= "" . // for CKE plugins "" . "" . // edit "" . // view $this->wire()->session->CSRF->renderInput() . ($css ? "" : "") . "" . ""; // to test width to see if font-awesome already loaded foreach($cssFiles as $cssFile) { $out .= ""; } return $out; } /** * Get Inputfield * * @param Page $page * @param Field $field * @return Inputfield * */ protected function getInputfield(Page $page, Field $field) { $inputfield = $field->___getInputfield($page); $inputfield->attr('name', $field->name); if(wireInstanceOf($inputfield, 'InputfieldTinyMCE')) { $inputfield->set('inlineMode', 1); $sf = $inputfield->get('settingsField'); if($sf && $sf = $this->wire()->fields->get($sf)) $sf->setQuietly('inlineMode', 1); } return $inputfield; } /** * Render the inline editor * * @param Page $page * @param Field $field * @param string $formatted Formatted value * @return string * */ protected function inlineRenderEditor(Page $page, Field $field, $formatted) { if(strpos($formatted, "id=pw-editor-$field->name")) return $formatted; $unformatted = $this->getUnformattedValue($page, $field); $this->inlineEditors[$field->name] = $field->name; $langID = $this->wire()->languages ? $this->wire()->user->language->id : 0; $attr = ''; // make sure we've got any initialization from the Inputfield $inputfield = $this->getInputfield($page, $field); $inputfield->renderReady(null, false); if(wireInstanceOf($inputfield, 'InputfieldTinyMCE')) { // Ensure TinyMCE is in inline mode and we have required attributes foreach($inputfield->wrapAttr() as $attrName => $attrVal) { // i.e. data-configName, data-features, data-settings, etc. if(strpos($attrName, 'data-') !== 0) continue; $attrVal = htmlspecialchars($attrVal); $attr .= "$attrName='$attrVal' "; } } // use div tags for 'textarea' fields and 'span' tags for text fields $tag = $field->type instanceof FieldtypeTextarea ? 'div' : 'span'; $this->editorNum++; $attr = trim( "id=pw-edit-$this->editorNum " . "class='pw-edit pw-edit-$inputfield' " . "data-name=$field->name " . "data-page=$page->id " . "data-lang='$langID' " . "style='position:relative' " . "$attr " ); return "<$tag $attr>" . "<$tag class=pw-edit-orig>" . $formatted . "" . "<$tag class=pw-edit-copy id=pw-editor-$field->name-$page->id " . "style='display:none;-webkit-user-select:text;user-select:text;' contenteditable>" . $unformatted . "" . ""; } /** * Render the modal editor * * Take the given markup and render a modal editor around it for the given Page and Field * * @param Page $page * @param array $fields Array of Field objects * @param string $markup * @return string * */ protected function modalRenderEditor(Page $page, array $fields, $markup) { $modalID = $this->getModalID($page, $fields); $tag = 'div'; $_fields = array(); foreach($fields as $field) $_fields[] = $field->name; $fieldsStr = implode(',', $_fields); $editURL = $page->editUrl() . "&fields=$fieldsStr&modal=1"; $this->editorNum++; if($this->wire()->input->get('pw_edit_fields')) { $markup = preg_replace('/\.(gif|png|jpg|jpeg)\b/i', '.$1?nocache=' . time(), $markup); } $out = "<$tag " . "id=pw-edit-modal-$modalID " . "class='pw-modal pw-modal-dblclick pw-edit-modal' " . "data-href='$editURL' " . "data-fields='$fieldsStr' " . "data-buttons='button.ui-button[type=submit]' " . "data-autoclose=''>" . $markup . ""; return $out; } /** * Get next available modal editor ID attribute * * @param Page $page * @param Field|string|array $field * @return string * */ protected function getModalID(Page $page, $field) { if(is_array($field)) { $fields = array(); foreach($field as $f) { $fields[] = $f instanceof Field ? $f->id : $f; } $field = implode('-', $fields); } else if($field instanceof Field) { $field = $field->id; } $modalID = "$page->id-$field"; $n = 0; while(isset($this->modalEditors[$modalID])) { $n++; $modalID = "$page->id-{$field}_$n"; } $this->modalEditors[$modalID] = $field; return $modalID; } /** * Like str_replace but only replaces one rather than all * * @param string $search * @param string $replace * @param string $subject * @return string * */ protected function strReplaceOne($search, $replace, $subject) { $first = strpos($subject, $search); $last = strrpos($subject, $search); if($first !== false) { if($first === $last) return str_replace($search, $replace, $subject); $before = substr($subject, 0, $first); $after = substr($subject, $first + strlen($search)); return $before . $replace . $after; } else { return $subject; } } /** * Execute the ajax page save action * * Outputs JSON and halts execution. * */ protected function inlineSaveEdits() { $this->inlineEditorActive = true; $session = $this->wire()->session; $input = $this->wire()->input; $user = $this->wire()->user; $pageID = (int) $input->post('id'); $langID = (int) $input->post('language'); $postFields = $input->post('fields'); // JSON return data $data = array( 'status' => 0, // 0=error, 1=success, 2=success but with errors 'error' => '', // new line separated errors 'changes' => '', // csv field names 'formatted' => array(), // formatted field values 'unformatted' => array(), // unformatted field values ); if(!$this->page || !$this->page->id) { $data['error'] = "Page is not available for front-end edits"; } else if($pageID != $this->page->id) { $data['error'] = "Edited page does not match current page ID ($pageID != {$this->page->id})"; } else if($this->wire('languages') && ($langID != $user->language->id)) { $data['error'] = "Edited language does not match current language ($langID != {$this->user->language})"; } else if(!$this->page->editable()) { $data['error'] = "Page is not editable by this user (page $pageID, user {$this->user->name})"; } else if(!is_array($postFields)) { $data['error'] = "No changes to save"; } else { // okay to make edits try { $session->CSRF->validate(); $data = $this->inlineProcessSaveEdits($postFields, $data); } catch(WireCSRFException $e) { $data['error'] = "Failed CSRF check"; } } $http = new WireHttp(); $this->wire($http); $http->sendHeader("Content-type: application/json"); $http->sendStatusHeader(200); echo json_encode($data); exit; } /** * Save the given fields to the page and populate the result $data array * * @param array $postFields * @param array $data * @return array * */ protected function inlineProcessSaveEdits(array $postFields, array $data) { $sanitizer = $this->wire()->sanitizer; $languages = $this->wire()->languages; $fields = $this->wire()->fields; $pages = $this->wire()->pages; $input = $this->wire()->input; $user = $this->wire()->user; $language = $languages ? $user->language : null; $pages->uncacheAll(); $pages->setOutputFormatting(false); $errors = array(); $pagesToSave = array(); $names = array(); $draft = (int) $input->get('draft'); foreach($postFields as $key => $value) { if($sanitizer->name($key) != $key) { unset($postFields[$key]); continue; } list($pageID, $name) = explode('__', $key, 2); $name = $sanitizer->fieldName($name); $names[$key] = $name; $field = $fields->get($name); $useLanguages = in_array('FieldtypeLanguageInterface', wireClassImplements($field->type)); $pageID = (int) $pageID; if(!$pageID) continue; if(isset($pagesToSave[$pageID])) { $page = $pagesToSave[$pageID]; } else { if($pageID == $this->wire()->page->id) { // ensure we are using same instance as the one loaded $page = $this->wire()->page; } else { $page = $pages->get($pageID); } if(!$page->id) continue; $page->resetTrackChanges(true); $page->setOutputFormatting(false); $pagesToSave[$pageID] = $page; } if(!$field) { $errors[] = "Field '$name' does not exist."; continue; } if(!$this->inlineIsSaveable($page, $field)) { $errors[] = "Field '$name' is not saveable."; continue; } // let the Inputfield process the input $inputfield = $this->getInputfield($page, $field); $postName = $name; // jQuery HTML function entity encodes things like & to & even if they don't appear in the source // so we determine if it's necessary to decode them here if($inputfield instanceof InputfieldTextarea) { if(wireInstanceOf($inputfield, 'InputfieldCKEditor')) { $decode = false; } else if(wireInstanceOf($inputfield, 'InputfieldTinyMCE')) { $postName = "Inputfield_$name"; $decode = false; } else if($field->get('contentType') >= FieldtypeTextarea::contentTypeHTML) { $decode = false; } else { $decode = true; } } else if($inputfield instanceof InputfieldText) { $decode = true; } else { $decode = false; } if($decode) { $value = $sanitizer->unentities($value); } $input->post->$postName = $value; $inputfield->attr('name', $name); $inputfield->val($page->getUnformatted($name)); $inputfield->resetTrackChanges(true); $inputfield->processInput($input->post); $_errors = $inputfield->getErrors(true); if(count($_errors)) { foreach($_errors as $error) { if(strpos($error, $name) === false) $error .= " (field=$name)"; $errors[] = $error; } continue; } if($inputfield->isChanged()) { $page->of(false); if($language && $useLanguages) { $value = $page->get($name); if(is_object($value) && in_array('LanguagesValueInterface', wireClassImplements($value))) { /** @var LanguagesValueInterface $value */ $value->setLanguageValue($language, $inputfield->val()); $page->set($name, $value); $page->trackChange($name); } else { $page->set($name, $inputfield->val()); } } else { $page->set($name, $inputfield->val()); } } } // save the pages that were modified foreach($pagesToSave as $page) { $changes = $page->getChanges(); if(count($changes)) { try { $page->save(); $data['status'] = count($errors) ? 2 : 1; } catch(\Exception $e) { $data['status'] = $data['status'] ? 2 : 0; $error = $e->getMessage(); $errors[] = $error; } } else { if(!$data['status']) $data['status'] = 3; // no changes } if(count($errors)) { $data['error'] .= " \n" . implode(" \n", $errors); } if($draft && $page->id == $this->wire()->page->id) { // use existing $page } else { // get fresh copy of page $page = $pages->get((int) $page->id); } $page->of(false); foreach($postFields as $key => $value) { if(strpos($key, $page->id . '__') !== 0) continue; $name = $names[$key]; $data['unformatted'][$key] = (string) $this->getUnformattedValue($page, $name); $data['formatted'][$key] = (string) $page->getFormatted($name); } } $data['error'] = trim($data['error']); return $data; } /** * Get an unformatted Page value suitable for inclusion in existing markup * * @param Page $page * @param Field|string $name Field object or name * @return string|int|float * */ protected function getUnformattedValue(Page $page, $name) { if($name instanceof Field) { $field = $name; $name = $field->name; } else { $field = $this->wire()->fields->get($name); } $unformatted = $page->getUnformatted($name); if(is_object($unformatted)) $unformatted = (string) $unformatted; $purifyHTML = true; if($field && $field->type instanceof FieldtypeTextarea) { $contentType = (int) $field->get('contentType'); $cls = $field->get('inputfieldClass'); if($cls === 'InputfieldCKEditor' || $cls === 'InputfieldTinyMCE' || $contentType == 1 || $contentType == 2) { // HTML is expected and allowed $purifyHTML = false; } } if(is_string($unformatted) && $purifyHTML && (strpos($unformatted, '<') !== false || strpos($unformatted, '&') !== false)) { // string might have some HTML in it, allow only a purified version through $unformatted = trim($unformatted); $unformatted = $this->wire()->sanitizer->purify(trim($unformatted)); } return $unformatted; } }